Advanced Find: every match, Reading Highlight, search within a selection (#234)

Home › Editing › Find › Advanced Find (edit.advancedFind) opens the Find
and Replace dialog. With params it returns every match in the body, or
only inside the selection ("in": "selection"), and "highlight": true
turns on Reading Highlight, which marks all matches on the page and
follows edits until it's turned off. The ribbon's Find button becomes a
menu with Find, Advanced Find and Go To; the Find dialog gets a Reading
Highlight toggle. Parity 355/405.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov 2026-10-10 19:45:35 +02:00 committed by GitHub
parent d8f71959d3
commit 9c3ad84d33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 151 additions and 7 deletions

View File

@ -9,7 +9,7 @@ and agent control. This file tracks where we are honestly. Generated numbers com
| Measure | Value |
|---|---|
| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
| Feature catalog coverage (Word ribbon/menu features with a command) | **380 / 430 (88%)** |
| Feature catalog coverage (Word ribbon/menu features with a command) | **381 / 430 (89%)** |
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |

View File

@ -36,6 +36,9 @@ pub fn specs() -> Vec<CommandSpec> {
.key("Mod+F")
.params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
.pure(),
CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find)
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool, "in"?: "main|selection", "highlight"?: bool}"#)
.pure(),
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3").pure(),
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)).key("Mod+Shift+G").pure(),
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
@ -115,7 +118,7 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
/// paragraph without it: a match lies within one stretch of live text, and word
/// boundaries and anchors see the neighbouring live text, not the deleted text.
/// Otherwise Replace would find its own deleted text again.
fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
pub(crate) fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
use regex_automata::{Input, meta, util::syntax};
let f = &s.find;
if f.query.is_empty() {
@ -160,6 +163,8 @@ fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
}
fn read_opts(s: &mut Session, v: &Value) {
// A new search changes what Reading Highlight marks.
s.find.highlights = None;
if let Some(t) = p::str(v, "text") {
s.find.query = t.to_string();
}
@ -199,6 +204,35 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
}))
}
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
s.ui_requests.push(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
read_opts(s, v);
let within = match p::str(v, "in").unwrap_or("main") {
"main" => None,
"selection" if !s.sel.is_collapsed() => Some(s.sel.ordered()),
"selection" => return Err(CmdError::Params("`in: selection` needs a selection".into())),
other => return Err(CmdError::Params(format!("`in` is main or selection, not `{other}`"))),
};
let story = within.as_ref().map(|(a, _)| a.story).unwrap_or(StoryRef::Body);
let mut found = search(s, story)?;
if let Some((a, b)) = &within {
found.retain(|(x, y)| x >= a && y <= b);
}
if let Some(h) = p::bool(v, "highlight") {
s.find.highlight = h;
s.find.highlights = None;
}
let n = found.len();
let matches: Vec<Value> = found.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect();
s.find.current = 0;
s.find.results = found;
Ok(json!({"count": n, "matches": matches, "highlight": s.find.highlight}))
}
fn step(s: &mut Session, dir: i64) -> CmdResult {
let story = s.sel.focus.story;
s.find.results = search(s, story)?;

View File

@ -98,6 +98,11 @@ pub struct FindState {
#[serde(skip)]
pub results: Vec<(Pos, Pos)>,
pub current: usize,
/// Reading Highlight: mark every match of `query` in the body.
pub highlight: bool,
/// Highlighted matches, for the document revision they were found in.
#[serde(skip)]
pub highlights: Option<(u64, Vec<(Pos, Pos)>)>,
}
#[derive(Clone)]
@ -304,6 +309,18 @@ impl Session {
self.dirty = true;
}
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
if !self.find.highlight {
return &[];
}
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
self.find.highlights = Some((self.rev, found));
}
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
}
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let ww = self.view.web_width;

View File

@ -1701,6 +1701,37 @@ fn timestamps_come_from_the_clock() {
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
}
/// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight.
#[test]
fn advanced_find_lists_and_highlights_matches() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Cat"}));
// From the UI it opens the dialog.
run(&mut s, "edit.advancedFind", json!({}));
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "find"})));
let r = run(&mut s, "edit.advancedFind", json!({"text": "cat", "highlight": true}));
assert_eq!(r["count"], 3);
assert_eq!(s.find_highlights().len(), 3);
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "matchCase": true}))["count"], 2);
// Highlights follow edits and the latest search.
assert_eq!(s.find_highlights().len(), 2);
run(&mut s, "text.insert", json!({"text": " cat"}));
assert_eq!(s.find_highlights().len(), 3);
// Only inside the selection.
run(&mut s, "select.text", json!({"text": "cat dog"}));
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "in": "selection"}))["count"], 1);
run(&mut s, "edit.advancedFind", json!({"highlight": false}));
assert!(s.find_highlights().is_empty());
// Hostile params.
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "elsewhere"})).is_err());
assert!(s.run("edit.advancedFind", &json!({"text": "(", "regex": true})).is_err());
run(&mut s, "select.all", json!({}));
run(&mut s, "text.delete", json!({}));
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "selection"})).is_err());
}
/// #146: a table style made by command styles the table at the caret (header row fill and bold
/// text, banded rows), can be modified, and saves as a table style the table refers to.
#[test]

View File

@ -480,6 +480,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
app.canvas.textures.retain(|k, _| *k < n);
app.canvas.page_rects = rects.clone();
balloons(app, ui, &painter, &rects, &layout, geo.scale);
// Find › Reading Highlight.
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
for (a, b) in &marks {
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
if let Some(pr) = rects.get(pi) {
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
let hr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
painter.rect_filled(hr, 0.0, t.find_highlight);
}
}
}
// Selection (a selected object shows its frame instead).
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
let (a, b) = app.session.sel.ordered();

View File

@ -767,10 +767,24 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.checkbox(whole_word, tl!("Whole words"));
ui.checkbox(regex, tl!("Wildcards (regex)"));
});
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
if !*replace_mode {
ui.horizontal(|ui| {
let lit = app.session.find.highlight;
if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() {
let mut v = opts.clone();
v["highlight"] = json!(!lit);
match app.run("edit.advancedFind", v) {
Ok(r) if !lit => *message = crate::i18n::fmt(tl!("{count} items highlighted"), &[("count", &r["count"].to_string())]),
Ok(_) => message.clear(),
Err(e) => *message = e,
}
}
});
}
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).weak());
}
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
let mut close = false;
ui.horizontal(|ui| {
if *replace_mode {

View File

@ -35,6 +35,8 @@
Address\nBlock Bloque de\ndirección
Adjust Ajustar
Advanced Avanzado
Advanced Find Búsqueda avanzada
Advanced Find… Búsqueda avanzada…
After: Después:
Agents Agentes
Align Alinear
@ -274,6 +276,7 @@
General General
Go To Ir a
Go To (⌘⌥G) Ir a (⌘⌥G)
Go To… Ir a…
Go to Ir a
Grab bag Bolsa de datos adicionales
Greeting Line Línea de saludo
@ -527,6 +530,7 @@
Read Mode Modo lectura
Read\nAloud Leer en\nvoz alta
Read\nMode Modo\nlectura
Reading Highlight Resaltado de lectura
Recent Recientes
Record Macro Grabar macro
Recover Unsaved Documents Recuperar documentos sin guardar
@ -955,3 +959,4 @@
Apply formatting to: Aplicar formato a:
Whole Table Toda la tabla
Fill color: Color de relleno:
{count} items highlighted {count} elementos resaltados

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -931,6 +931,10 @@
Dark ダーク
Use system setting システム設定を使用
A file dialog is already open. ファイル ダイアログが既に開いています。
Advanced Find 高度な検索
Advanced Find… 高度な検索…
Go To… ジャンプ…
Reading Highlight 検索結果を強調表示
New Table Style 新しい表スタイル
Modify Table Style 表スタイルの変更
New Table Style… 新しい表スタイル…
@ -938,3 +942,4 @@
Apply formatting to: 書式の適用先:
Whole Table 表全体
Fill color: 塗りつぶしの色:
{count} items highlighted {count} 件を強調表示しました

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -943,6 +943,10 @@
Dark Escuro
Use system setting Usar a configuração do sistema
A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta.
Advanced Find Localização avançada
Advanced Find… Localização avançada…
Go To… Ir para…
Reading Highlight Realçar resultados
New Table Style Novo estilo de tabela
Modify Table Style Modificar estilo de tabela
New Table Style… Novo estilo de tabela…
@ -950,3 +954,4 @@
Apply formatting to: Aplicar formatação a:
Whole Table Tabela inteira
Fill color: Cor de preenchimento:
{count} items highlighted {count} itens realçados

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -32,6 +32,8 @@
Address\nBlock Блок\nадреси
Adjust Налаштувати
Advanced Додатково
Advanced Find Розширений пошук
Advanced Find… Розширений пошук…
After: Після:
Agents Агенти
Align Вирівняти
@ -277,6 +279,7 @@
Go To Перейти
Go to Перейти
Go To (⌘⌥G) Перейти (⌘⌥G)
Go To… Перейти…
Grab bag Різне
Greeting Line Рядок привітання
Greeting\nLine Рядок\nпривітання
@ -528,6 +531,7 @@
Read Mode Режим читання
Read\nAloud Читати\nвголос
Read\nMode Режим\nчитання
Reading Highlight Виділення під час читання
Recent Останні
Record Macro Записати макрос
Recover Unsaved Documents Відновити незбережені документи
@ -950,3 +954,4 @@
Apply formatting to: Застосувати форматування до:
Whole Table Уся таблиця
Fill color: Колір заливки:
{count} items highlighted Виділено елементів: {count}

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -942,6 +942,10 @@
Dark 深色
Use system setting 使用系统设置
A file dialog is already open. 已有一个文件对话框处于打开状态。
Advanced Find 高级查找
Advanced Find… 高级查找…
Go To… 定位…
Reading Highlight 突出显示查找结果
New Table Style 新建表格样式
Modify Table Style 修改表格样式
New Table Style… 新建表格样式…
@ -949,3 +953,4 @@
Apply formatting to: 将格式应用于:
Whole Table 整个表格
Fill color: 填充颜色:
{count} items highlighted 已突出显示 {count} 项

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -932,6 +932,10 @@
Dark 深色
Use system setting 使用系統設定
A file dialog is already open. 已有一個檔案對話方塊開啟中。
Advanced Find 進階尋找
Advanced Find… 進階尋找…
Go To… 移至…
Reading Highlight 醒目提示尋找結果
New Table Style 新增表格樣式
Modify Table Style 修改表格樣式
New Table Style… 新增表格樣式…
@ -939,3 +943,4 @@
Apply formatting to: 將格式套用至:
Whole Table 整個表格
Fill color: 填滿色彩:
{count} items highlighted 已醒目提示 {count} 個項目

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -457,7 +457,11 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
});
group(ui, "Editing", None, app, |ui, app| {
stack(ui, |ui| {
small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked();
menu_button(ui, app, "find", Some("Find"), "Find", false, |ui, app| {
mi(ui, app, "Find", "ui.dialog", json!({"name": "find"}));
mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({}));
mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"}));
});
small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false);
menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| {
mi(ui, app, "Select All", "select.all", json!({}));

View File

@ -99,6 +99,8 @@ pub struct Tokens {
pub ruler_tick: Color32,
pub status_bar: Color32,
pub selection: Color32,
/// Find › Reading Highlight.
pub find_highlight: Color32,
pub caret: Color32,
pub page_shadow: Color32,
/// Selection-frame handles (pictures, shapes, text boxes) and their shadow.
@ -140,6 +142,7 @@ impl Tokens {
ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70),
status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0),
selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),
find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90),
caret: Color32::BLACK,
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),
handle: Color32::WHITE,
@ -179,6 +182,7 @@ impl Tokens {
ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0),
status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F),
selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),
find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70),
caret: Color32::BLACK,
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),
handle: Color32::from_rgb(0xF2, 0xF2, 0xF2),

View File

@ -2,13 +2,13 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**380 of 430 catalog features have commands (88.4%).**
**381 of 430 catalog features have commands (88.6%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| Quick Access | 4 | 4 |
| Home | 92 | 101 |
| Home | 93 | 101 |
| Insert | 42 | 53 |
| Draw | 0 | 11 |
| Design | 10 | 10 |
@ -37,7 +37,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Paragraph › Asian Typography (`para.asianTypography`)
- Styles › Style Inspector (`styles.inspector`)
- Styles › Manage Styles (`styles.manage`)
- Editing › Advanced Find (`edit.advancedFind`)
- Voice › Dictate (`tools.dictate`)
### Insert