Insert › Bookmark: Delete and Go To, laid out as Word's (#1236) (#1277)

* Insert › Bookmark: Delete and Go To, laid out as Word's (#1236)

The Bookmark dialog had only Add: a bookmark could not be deleted, and a
click in its list jumped to the bookmark at once. It is now laid out as
Word's (observed in Word 2019 on Windows): the name box over the list, Add,
Delete and Go To beside them, Sort by Name or Location and Hidden bookmarks
below. A click in the list picks a name; Add (Enter) adds and closes; Delete
and Go To leave the dialog open, and after a delete Cancel reads Close. The
dialog opens on the bookmark around the caret, and Add is off for a name
the command would refuse.

insert.deleteBookmark {name} removes a bookmark's start and end wherever
they are, also across paragraphs, as one undo step, keeping the selection
on its text; from the command search without a name it opens the dialog.
Adding a bookmark under a name in use goes through the same removal: it
looked for the old end only in the start's paragraph, so a bookmark across
paragraphs left a stray end behind. edit.goto with a bookmark selects its
text, as Word's Go To does, instead of placing the caret at its start.

Closes #1236

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

* Insert › Bookmark: Ctrl+click on a link to a bookmark keeps a caret (#1236)

Word's Go To selects a bookmark's text, but Ctrl+click on a link to a
bookmark only puts the caret at the bookmark's start (checked in Word 2019
on Windows). Following a link runs edit.goto, which now selects, so it
collapses the selection to its start.

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

* Resolve the doc conflict markers left by the previous merge

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
rabis7575
2026-10-14 18:16:55 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 44787764a9
commit 40cf85c25b
26 changed files with 378 additions and 32 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: an option to show only the selected text's comment, #1055; previously trivial: progress log: a window too big for its screen shrinks to fit, #1215; 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: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; 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.
@@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested |
| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) |
| 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused |
| 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 |
@@ -349,6 +350,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) |
| 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) |
+4 -3
View File
@@ -132,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
.pure(),
CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| {
let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?;
@@ -684,8 +684,9 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
return sel_result(s);
}
if let Some(name) = p::str(v, "bookmark") {
let bm = s.doc.bookmarks().into_iter().find(|(n, _)| n == name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?;
s.sel = Selection::caret(bm.1);
// As in Word, the bookmark's text is selected (a caret for an empty bookmark).
let (from, to) = super::insert::bookmark_range(&s.doc, name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?;
s.sel = Selection { anchor: from, focus: to };
return sel_result(s);
}
if let Some(n) = p::u64(v, "paragraph") {
+126 -9
View File
@@ -44,6 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
})),
CommandSpec::new("insert.bookmark", "Bookmark", "Insert › Links", bookmark).params(r#"{"name": string}"#).key("Mod+Shift+F5"),
CommandSpec::new("insert.deleteBookmark", "Delete Bookmark", "Insert › Links", delete_bookmark).params(r#"{"name": string}"#),
CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#),
CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#),
CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#),
@@ -394,20 +395,85 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Whether `name` can name a bookmark: no spaces, at most 40 characters, not starting with a digit.
pub fn bookmark_name_ok(name: &str) -> bool {
!name.is_empty() && !name.contains(' ') && name.len() <= 40 && !name.chars().next().is_some_and(|c| c.is_ascii_digit())
}
/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark.
pub fn bookmark_range(doc: &wordcraft_doc::Document, name: &str) -> Option<(Pos, Pos)> {
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
Some(bookmark_span(doc, name, &start))
}
/// The text of bookmark `name` whose start mark is at `start`: from just after that mark to its
/// end mark — in a later paragraph when it spans several (the start again when it has no end).
pub fn bookmark_span(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> (Pos, Pos) {
let from = Pos { off: start.off.saturating_add(wordcraft_doc::para::OBJ.len_utf8()), ..start.clone() };
let end = doc.para_paths(start.story).into_iter().skip_while(|p| *p != start.path).find_map(|path| {
let para = doc.para(start.story, &path)?;
let off = para.object_offsets().into_iter().find(|o| {
(path != start.path || *o > start.off) && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name)
})?;
Some(Pos { story: start.story, path, off })
});
let end = end.unwrap_or_else(|| from.clone());
(from, end)
}
/// Remove bookmark `name`'s start and end marks, in whichever paragraphs and stories they are,
/// keeping the selection on the same text. Returns whether the bookmark was there.
fn remove_bookmark(s: &mut Session, name: &str) -> Result<bool, CmdError> {
let mark_len = wordcraft_doc::para::OBJ.len_utf8();
let mut found = false;
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for story in stories {
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
let mut marks = Vec::new();
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::BookmarkStart { name: n }) if n == name => {
found = true;
marks.push(off);
}
Some(InlineObject::BookmarkEnd { name: n }) if n == name => marks.push(off),
_ => {}
}
}
if marks.is_empty() {
continue;
}
let para = s.doc.para_mut(story, &path)?;
for off in marks.into_iter().rev() {
para.delete(off, off + mark_len)?;
for at in [&mut s.sel.anchor, &mut s.sel.focus] {
if at.story == story && at.path == path && at.off > off {
at.off = at.off.saturating_sub(mark_len);
}
}
}
}
}
s.clamp_selection();
Ok(found)
}
fn delete_bookmark(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim();
if !remove_bookmark(s, name)? {
return Err(CmdError::Params(format!("no bookmark `{name}`")));
}
sel_result(s)
}
fn bookmark(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() || name.contains(' ') || name.len() > 40 || name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
if !bookmark_name_ok(&name) {
return Err(CmdError::Params("bookmark names start with a letter, have no spaces and at most 40 characters".into()));
}
// Replace an existing bookmark of the same name.
for (_, pos) in s.doc.bookmarks().into_iter().filter(|(n, _)| *n == name).rev() {
let para = s.doc.para_mut(pos.story, &pos.path)?;
para.delete(pos.off, pos.off + wordcraft_doc::para::OBJ.len_utf8())?;
let offs: Vec<usize> = para.object_offsets();
if let Some(e) = offs.into_iter().find(|o| matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if *n == name)) {
para.delete(e, e + wordcraft_doc::para::OBJ.len_utf8())?;
}
}
remove_bookmark(s, &name)?;
let (a, b) = s.sel.ordered();
let props = CharProps::default();
let end = s.doc.insert_object(&b, InlineObject::BookmarkEnd { name: name.clone() }, &props)?;
@@ -940,6 +1006,57 @@ mod tests {
assert!(base64_decode("!!!").is_none());
}
/// Every bookmark mark in the body, in order: (paragraph, start or end, name).
fn bookmark_marks(s: &Session) -> Vec<(usize, bool, String)> {
let mut out = Vec::new();
for (i, path) in s.doc.para_paths(StoryRef::Body).iter().enumerate() {
let p = s.doc.para(StoryRef::Body, path).unwrap();
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::BookmarkStart { name }) => out.push((i, true, name.clone())),
Some(InlineObject::BookmarkEnd { name }) => out.push((i, false, name.clone())),
_ => {}
}
}
}
out
}
/// Insert › Bookmark › Delete (#1236): a bookmark across two paragraphs loses both its marks,
/// the text and the selection stay where they were, and one undo brings it back. Go To
/// selects the bookmark's text, as in Word.
#[test]
fn deleting_a_bookmark_removes_its_start_and_end() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha beta\ngamma delta"));
s.sel = Selection { anchor: Pos::body(0, 6), focus: Pos::body(1, 5) };
s.run("insert.bookmark", &json!({"name": "Across"})).unwrap();
assert_eq!(bookmark_marks(&s), vec![(0, true, "Across".into()), (1, false, "Across".into())]);
s.run("edit.goto", &json!({"bookmark": "Across"})).unwrap();
let picked = s.selected_text();
assert!(picked.starts_with("beta") && picked.ends_with("gamma"), "{picked:?}");
s.run("select.text", &json!({"text": "delta"})).unwrap();
s.run("insert.deleteBookmark", &json!({"name": "Across"})).unwrap();
assert!(bookmark_marks(&s).is_empty(), "{:?}", bookmark_marks(&s));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha beta\ngamma delta");
assert_eq!(s.selected_text(), "delta", "the selection stays on its text");
assert!(s.run("insert.deleteBookmark", &json!({"name": "Across"})).is_err(), "gone");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(bookmark_marks(&s).len(), 2, "one undo step");
}
/// Adding a bookmark under a name in use moves it, and the old end goes too — it was only
/// looked for in the start's paragraph, so a bookmark across paragraphs left a stray end.
#[test]
fn adding_a_bookmark_again_moves_all_of_it() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha beta\ngamma delta"));
s.sel = Selection { anchor: Pos::body(0, 6), focus: Pos::body(1, 5) };
s.run("insert.bookmark", &json!({"name": "Spot"})).unwrap();
s.run("select.text", &json!({"text": "delta"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Spot"})).unwrap();
assert_eq!(bookmark_marks(&s), vec![(1, true, "Spot".into()), (1, false, "Spot".into())]);
assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta"));
}
/// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after.
#[test]
fn symbol_takes_its_own_font() {
+39 -1
View File
@@ -2044,7 +2044,10 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
{
if let Some(name) = link.strip_prefix('#') {
let _ = app.run("edit.goto", json!({"bookmark": name}));
// The caret goes to the bookmark's start, as in Word (Go To selects its text).
if app.run("edit.goto", json!({"bookmark": name})).is_ok() {
let _ = app.run("select.collapse", json!({}));
}
} else if is_followable_link(&link) {
app.canvas.open_url = Some(link);
} else {
@@ -3565,6 +3568,41 @@ mod tests {
}
}
/// Ctrl+click on a link to a bookmark puts the caret at the bookmark's start, as Word does;
/// only Go To selects the bookmark's text (#1236).
#[test]
fn a_link_to_a_bookmark_puts_the_caret_at_its_start() {
let mut a =
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Alpha beta gamma\nSee here")), Default::default());
a.run("select.text", json!({"text": "beta"})).unwrap();
a.run("insert.bookmark", json!({"name": "Spot"})).unwrap();
a.run("select.text", json!({"text": "here"})).unwrap();
a.run("insert.link", json!({"url": "#Spot"})).unwrap();
a.run("caret.docEnd", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
steps(&mut h, 6);
// Between the link's "h" and "e".
let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 5 }).unwrap();
let at = screen(&mut h, c.x, c.top + c.height / 2.0);
h.event(egui::Event::PointerMoved(at));
h.step();
let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND };
h.event_modifiers(press, egui::Modifiers::COMMAND);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
steps(&mut h, 3);
let s = &h.state().session;
let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap();
assert!(s.sel.is_collapsed(), "a caret, not a selection: {:?}", s.sel);
assert_eq!(s.sel.focus, start, "at the bookmark's start");
}
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
/// the scrollable range.
#[test]
+144 -16
View File
@@ -267,6 +267,12 @@ pub enum Dialog {
},
Bookmark {
name: String,
/// Listed in document order (Word's Sort by Location) rather than by name.
by_location: bool,
/// Hidden bookmarks (`_Toc…`, `_Ref…`, made for tables of contents and cross-references) listed too.
hidden: bool,
/// A bookmark was deleted, so Cancel reads Close (as in Word).
deleted: bool,
},
WordCount {
stats: Value,
@@ -1175,7 +1181,7 @@ impl Dialog {
}
}
"link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text() },
"bookmark" => Dialog::Bookmark { name: String::new() },
"bookmark" => Dialog::Bookmark { name: bookmark_at_caret(&app.session), by_location: false, hidden: false, deleted: false },
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
@@ -2200,21 +2206,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ok || cancel
}
Dialog::Bookmark { name } => {
ui.label(tl!("Bookmark name:"));
ui.text_edit_singleline(name);
let marks = app.session.doc.bookmarks();
for (n, _) in &marks {
if ui.selectable_label(false, n).clicked() {
let _ = app.run("edit.goto", json!({"bookmark": n}));
}
}
let (ok, cancel) = buttons(ui, tl!("Add"));
if ok {
let _ = app.run("insert.bookmark", json!({"name": name}));
}
ok || cancel
}
Dialog::Bookmark { name, by_location, hidden, deleted } => bookmark(app, ui, name, by_location, hidden, deleted),
Dialog::WordCount { stats } => {
egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| {
for (l, k) in [
@@ -3292,6 +3284,92 @@ fn apply_asian(app: &mut WordApp, asian: [bool; 6], was: [bool; 6]) {
}
}
/// The innermost bookmark around the caret, which the Bookmark dialog opens on (as Word does);
/// empty when the caret is in none.
fn bookmark_at_caret(s: &wordcraft_engine::Session) -> String {
let at = &s.sel.focus;
s.doc
.bookmarks()
.into_iter()
.rev()
.find(|(name, start)| !name.starts_with('_') && start <= at && *at <= wordcraft_engine::cmd::insert::bookmark_span(&s.doc, name, start).1)
.map(|(name, _)| name)
.unwrap_or_default()
}
/// The bookmarks the Bookmark dialog lists: by name or in document order, the hidden ones
/// (`_Toc…`, `_Ref…`) only when asked for.
fn bookmark_names(doc: &wordcraft_doc::Document, by_location: bool, hidden: bool) -> Vec<String> {
let mut names: Vec<String> = doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| hidden || !n.starts_with('_')).collect();
if !by_location {
names.sort_by_key(|n| n.to_lowercase());
}
names
}
/// Insert › Bookmark, laid out as Word's (#1236): the name box over the list, Add / Delete / Go To
/// beside them, Sort by and Hidden bookmarks below. Add (Enter) closes the dialog; Delete and Go
/// To leave it open, and once a bookmark is deleted Cancel reads Close.
fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut bool, hidden: &mut bool, deleted: &mut bool) -> bool {
let marks = bookmark_names(&app.session.doc, *by_location, *hidden);
let t = Tokens::get(ui.ctx());
let (mut add, mut delete, mut go_to) = (false, false, false);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label(tl!("Bookmark name:"));
ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
ui.set_width(232.0);
ui.set_height(150.0);
egui::ScrollArea::vertical().id_salt("bookmark_list").max_height(150.0).auto_shrink([false, false]).show(ui, |ui| {
for n in &marks {
if ui.selectable_label(*n == *name, n.as_str()).clicked() {
*name = n.clone();
}
}
});
});
});
ui.vertical(|ui| {
ui.add_space(18.0);
let listed = marks.iter().any(|n| n == name.trim());
let w = vec2(90.0, 0.0);
add = ui.add_enabled(wordcraft_engine::cmd::insert::bookmark_name_ok(name.trim()), egui::Button::new(tl!("Add")).min_size(w)).clicked();
delete = ui.add_enabled(listed, egui::Button::new(tl!("Delete")).min_size(w)).clicked();
go_to = ui.add_enabled(listed, egui::Button::new(tl!("Go To")).min_size(w)).clicked();
});
});
ui.add_space(6.0);
ui.horizontal_top(|ui| {
ui.label(tl!("Sort by:"));
ui.vertical(|ui| {
ui.radio_value(by_location, false, tl!("Name"));
ui.radio_value(by_location, true, tl!("Location"));
});
});
ui.checkbox(hidden, tl!("Hidden bookmarks"));
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
if add || (enter && wordcraft_engine::cmd::insert::bookmark_name_ok(name.trim())) {
let _ = app.run("insert.bookmark", json!({"name": name.trim()}));
return true;
}
if delete && app.run("insert.deleteBookmark", json!({"name": name.trim()})).is_ok() {
name.clear();
*deleted = true;
}
if go_to {
let _ = app.run("edit.goto", json!({"bookmark": name.trim()}));
}
ui.add_space(8.0);
let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close |= ui.button(if *deleted { tl!("Close") } else { tl!("Cancel") }).clicked();
});
});
close
}
/// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command,
/// which would ask to open this dialog again) and acts through commands.
fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool {
@@ -3715,6 +3793,56 @@ mod tests {
}
}
/// Insert › Bookmark (#1236), as in Word: it opens on the bookmark around the caret, lists by
/// name, Go To selects a bookmark's text and Delete removes one — both leaving the dialog
/// open, Cancel then reading Close — and one undo brings the bookmark back.
#[test]
fn the_bookmark_dialog_goes_to_and_deletes_bookmarks() {
fn press(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str) {
use egui_kittest::kittest::Queryable;
let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "Bookmark")))).expect("the dialog is open");
h.query_all_by_label(label).find(|n| dialog.contains(n.rect().center())).expect(label).click();
for _ in 0..3 {
h.step();
}
}
let mut app = app_with("Alpha beta gamma");
for (word, name) in [("Alpha", "_Hidden"), ("beta", "Zulu"), ("gamma", "Yankee")] {
let _ = app.run("select.text", json!({"text": word}));
let _ = app.run("insert.bookmark", json!({"name": name}));
}
let doc = &app.session.doc;
assert_eq!(bookmark_names(doc, false, false), ["Yankee", "Zulu"], "by name, hidden ones left out");
assert_eq!(bookmark_names(doc, true, false), ["Zulu", "Yankee"], "by location");
assert_eq!(bookmark_names(doc, false, true), ["_Hidden", "Yankee", "Zulu"]);
let _ = app.run("select.text", json!({"text": "gamma"}));
app.dialog = Dialog::open("bookmark", &mut app);
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
assert!(matches!(&h.state().dialog, Some(Dialog::Bookmark { name, .. }) if name == "Yankee"), "opens on the caret's bookmark");
press(&mut h, "Zulu");
press(&mut h, "Go To");
assert_eq!(h.state().session.selected_text(), "beta");
press(&mut h, "Delete");
let names: Vec<String> = h.state().session.doc.bookmarks().into_iter().map(|(n, _)| n).collect();
assert_eq!(names, ["_Hidden", "Yankee"]);
assert!(matches!(&h.state().dialog, Some(Dialog::Bookmark { name, deleted: true, .. }) if name.is_empty()), "still open");
assert_eq!(h.state().session.selected_text(), "beta", "the text stays selected");
press(&mut h, "Close");
assert!(h.state().dialog.is_none());
let _ = h.state_mut().run("edit.undo", json!({}));
assert_eq!(h.state().session.doc.bookmarks().len(), 3, "one undo step");
}
/// Find, Replace and Go To are one dialog with three tabs (#712).
#[test]
fn find_replace_and_go_to_are_one_dialog() {
+3
View File
@@ -129,6 +129,9 @@
Bold Получер
Bookmark Отметка
Bookmark name: Име на отметката:
Delete Bookmark Изтриване на отметка
Hidden bookmarks Скрити отметки
Sort by: Сортиране по:
Border Painter Рисуване на граници
Border\nPainter Рисуване\nна граници
Borders Граници
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -140,6 +140,9 @@
Book Title Název knihy
Bookmark Záložka
Bookmark name: Název záložky:
Delete Bookmark Odstranit záložku
Hidden bookmarks Skryté záložky
Sort by: Seřadit podle:
Border Painter Štětec ohraničení
Border\nPainter Štětec\nohraničení
Borders Ohraničení
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -141,6 +141,9 @@
Bold Fett
Bookmark Textmarke
Bookmark name: Textmarkenname:
Delete Bookmark Textmarke löschen
Hidden bookmarks Ausgeblendete Textmarken
Sort by: Sortieren nach:
Border Painter Rahmenlinien-Maler
Border\nPainter Rahmenlinien-\nMaler
Borders Rahmenlinien
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -168,6 +168,9 @@
Bold Negrita
Bookmark Marcador
Bookmark name: Nombre del marcador:
Delete Bookmark Eliminar marcador
Hidden bookmarks Marcadores ocultos
Sort by: Ordenar por:
Book Title Título de libro
Border Painter Pincel de bordes
Border\nPainter Pincel de\nbordes
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -80,6 +80,9 @@
Bold Paks
Bookmark Järjehoidja
Bookmark name: Järjehoidja nimi:
Delete Bookmark Kustuta järjehoidja
Hidden bookmarks Peidetud järjehoidjad
Sort by: Sordi:
Book Title Raamatu pealkiri
Border Painter Äärisepintsel
Border\nPainter Äärise-\npintsel
1 # Estonian (et) interface catalog. Columns separated by a TAB:
80
81
82
83
84
85
86
87
88
+3
View File
@@ -127,6 +127,9 @@
Bold Gras
Bookmark Signet
Bookmark name: Nom du signet :
Delete Bookmark Supprimer le signet
Hidden bookmarks Signets masqués
Sort by: Trier par :
Border Painter Pinceau de bordure
Border\nPainter Pinceau\nde bordure
Borders Bordures
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -87,6 +87,9 @@
Book Title Könyvcím
Bookmark Könyvjelző
Bookmark name: Könyvjelző neve:
Delete Bookmark Könyvjelző törlése
Hidden bookmarks Rejtett könyvjelzők
Sort by: Rendezés:
Border Painter Szegélyfestő
Border\nPainter Szegély-\nfestő
Borders Szegélyek
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -126,6 +126,9 @@
Bold Grassetto
Bookmark Segnalibro
Bookmark name: Nome segnalibro:
Delete Bookmark Elimina segnalibro
Hidden bookmarks Segnalibri nascosti
Sort by: Ordina per:
Border Painter Copia bordi
Border\nPainter Copia\nbordi
Borders Bordi
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -185,6 +185,9 @@
Bold 太字
Bookmark ブックマーク
Bookmark name: ブックマーク名:
Delete Bookmark ブックマークの削除
Hidden bookmarks 隠しブックマーク
Sort by: 並べ替え:
Border Painter 罫線の書式設定
Border\nPainter 罫線の書式設定
Borders 罫線
1 # Japanese (ja) interface catalog. Same format and clean-room rules as zh-hans.tsv.
185
186
187
188
189
190
191
192
193
+3
View File
@@ -83,6 +83,9 @@
Bold Pogrubienie
Bookmark Zakładka
Bookmark name: Nazwa zakładki:
Delete Bookmark Usuń zakładkę
Hidden bookmarks Ukryte zakładki
Sort by: Sortuj według:
Border Painter Pędzel krawędzi
Border\nPainter Pędzel\nkrawędzi
Borders Krawędzie
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -164,6 +164,9 @@
Bold Negrito
Bookmark Indicador
Bookmark name: Nome do indicador:
Delete Bookmark Excluir indicador
Hidden bookmarks Indicadores ocultos
Sort by: Classificar por:
Border Painter Pincel de bordas
Border\nPainter Pincel\nde bordas
Borders Bordas
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -85,6 +85,9 @@
Book Title Название книги
Bookmark Закладка
Bookmark name: Имя закладки:
Delete Bookmark Удалить закладку
Hidden bookmarks Скрытые закладки
Sort by: Сортировать по:
Border Painter Кисть границ
Border\nPainter Кисть\nграниц
Borders Границы
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -242,6 +242,9 @@
Bold Podebljano
Bookmark Obeleživač
Bookmark name: Naziv obeleživača:
Delete Bookmark Obriši obeleživač
Hidden bookmarks Skriveni obeleživači
Sort by: Sortiraj po:
Border Painter Kopiranje ivica
Border\nPainter Kopiranje\nivica
Borders Ivice
Can't render this file because it contains an unexpected character in line 6 and column 25.
+3
View File
@@ -246,6 +246,9 @@
Bold Подебљано
Bookmark Обележивач
Bookmark name: Назив обележивача:
Delete Bookmark Обриши обележивач
Hidden bookmarks Скривени обележивачи
Sort by: Сортирај по:
Border Painter Копирање ивица
Border\nPainter Копирање\nивица
Borders Ивице
Can't render this file because it contains an unexpected character in line 11 and column 25.
+3
View File
@@ -84,6 +84,9 @@
Book Title Kitap Başlığı
Bookmark Yer İşareti
Bookmark name: Yer işareti adı:
Delete Bookmark Yer İşaretini Sil
Hidden bookmarks Gizli yer işaretleri
Sort by: Sıralama ölçütü:
Border Painter Kenarlık Fırçası
Border\nPainter Kenarlık\nFırçası
Borders Kenarlıklar
1 # Turkish (tr) interface catalog. Columns, separated by a TAB:
84
85
86
87
88
89
90
91
92
+3
View File
@@ -166,6 +166,9 @@
Bold Напівжирний
Bookmark Закладка
Bookmark name: Назва закладки:
Delete Bookmark Видалити закладку
Hidden bookmarks Приховані закладки
Sort by: Сортувати за:
Border Painter Пензель меж
Border\nPainter Пензель\nмеж
Borders Межі
Can't render this file because it contains an unexpected character in line 6 and column 31.
+5
View File
@@ -2601,3 +2601,8 @@
Show its number relative to this field Hiện số của nó tương đối với trường này
Show its number with all higher levels Hiện số của nó kèm mọi cấp cao hơn
Take the last match on the page Lấy kết quả khớp cuối cùng trên trang
# Bookmark dialog (#1277)
Delete Bookmark Xóa dấu trang
Hidden bookmarks Dấu trang ẩn
Sort by: Sắp xếp theo:
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -197,6 +197,9 @@
Bold 加粗
Bookmark 书签
Bookmark name: 书签名:
Delete Bookmark 删除书签
Hidden bookmarks 隐藏书签
Sort by: 排序依据:
Border Painter 边框刷
Border\nPainter 边框刷
Borders 边框
Can't render this file because it contains an unexpected character in line 7 and column 25.
+3
View File
@@ -186,6 +186,9 @@
Bold 粗體
Bookmark 書籤
Bookmark name: 書籤名稱:
Delete Bookmark 刪除書籤
Hidden bookmarks 隱藏書籤
Sort by: 排序依據:
Border Painter 框線複製
Border\nPainter 框線複製
Borders 框線
1 # Traditional Chinese (zh-hant, Taiwan vocabulary) interface catalog. Same format and clean-room
186
187
188
189
190
191
192
193
194
+1 -1
View File
@@ -2203,7 +2203,7 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"review.combine" if !has("path") && !has("text") => Some("combine"),
"tools.autocorrect" if params.as_object().is_none_or(|m| m.is_empty()) => Some("autoCorrect"),
"review.restrict" | "file.protect" if params.as_object().is_none_or(|m| m.is_empty()) => Some("restrictEditing"),
"insert.bookmark" if !has("name") => Some("bookmark"),
"insert.bookmark" | "insert.deleteBookmark" if !has("name") => Some("bookmark"),
// Word Count from the keyboard or command search shows its dialog, as the ribbon does.
"review.wordCount" if params.as_object().is_none_or(|m| m.is_empty()) => Some("wordCount"),
// Content control Properties without settings shows the dialog.
+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 (the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Cell Size Height/Width apply to the whole selection (#1242); 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 (Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; 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-13 | trivial | Insert › Bookmark as in Word (#1236): the name box over the list, Add / Delete / Go To beside them, Sort by Name or Location, Hidden bookmarks; it opens on the bookmark around the caret, Add (Enter) closes it, Delete and Go To keep it open (Cancel then reads Close), and Go To selects the bookmark's text, as Find and Replace › Go To does (Ctrl+click on a link to a bookmark only puts the caret at its start, as in Word). Not yet: with the caret in no bookmark Word offers the one added last; here the name box starts empty |
| 2026-10-13 | trivial | Table context menu: Delete is a submenu with Delete Cells, Columns, Rows and Table whatever is selected, so the caret in a cell is enough to delete the table (#1198) |
| 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) |