diff --git a/ROADMAP.md b/ROADMAP.md index ecc10ce..b9a31ea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1a86a75..b24616b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -36,6 +36,9 @@ pub fn specs() -> Vec { .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, CmdError> { +pub(crate) fn search(s: &Session, story: StoryRef) -> Result, 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, 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 = 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)?; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 56dd6da..d374548 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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 { let ww = self.view.web_width; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68ae8fc..0e713df 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d6c88b0..5c87d40 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c9a2114..16b7255 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19de3af..88f3a57 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4acca7d..a1ed376 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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} 件を強調表示しました diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8889245..318240f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17b84bd..b59e8ba 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 35b7f90..7faf9d4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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} 项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 58c2d70..da88b5f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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} 個項目 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0568985..c99b9a4 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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!({})); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 23276d1..a30a090 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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), diff --git a/docs/parity.md b/docs/parity.md index 2de2ef4..279962e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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