diff --git a/ROADMAP.md b/ROADMAP.md index 61b18c7a1..9c0a9416f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,8 +8,8 @@ and agent control. This file tracks where we are honestly. Generated numbers com | Measure | Value | |---|---| -| Commands (every action, scriptable by CLI/MCP/control channel) | **427** | -| Feature catalog coverage (Word ribbon/menu features with a command) | **382 / 430 (89%)** | +| Commands (every action, scriptable by CLI/MCP/control channel) | **430** | +| Feature catalog coverage (Word ribbon/menu features with a command) | **383 / 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/inspector.rs b/crates/engine/src/cmd/inspector.rs new file mode 100644 index 000000000..7bfd18ed9 --- /dev/null +++ b/crates/engine/src/cmd/inspector.rs @@ -0,0 +1,145 @@ +//! Style Inspector (Home › Styles): the paragraph style and the character style at the caret, +//! the direct formatting layered on each, and a way to clear each level. + +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; +use wordcraft_doc::props::{CharProps, ParaProps}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The levels `styles.inspectorClear` can reset. +pub const LEVELS: [&str; 4] = ["paragraphStyle", "paragraphFormatting", "characterStyle", "characterFormatting"]; + +/// Direct character properties that aren't formatting (links, revisions, proofing, direction). +const CHAR_SKIP: [&str; 7] = ["style", "link", "ins", "del", "lang", "noProof", "rtl"]; +/// Direct paragraph properties the inspector doesn't list (lists have their own UI). +const PARA_SKIP: [&str; 2] = ["style", "numbering"]; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("styles.inspector", "Style Inspector", "Home › Styles", |s, v| { + s.view.style_inspector = p::bool(v, "value").unwrap_or(!s.view.style_inspector); + let mut out = inspect(s); + if let Value::Object(m) = &mut out { + m.insert("pane".into(), json!(s.view.style_inspector)); + } + Ok(out) + }) + .params(r#"{"value"?: bool (show/hide the pane)}"#) + .pure(), + CommandSpec::new("styles.inspect", "Inspect Styles at Selection", "Home › Styles › Style Inspector", |s, _| Ok(inspect(s))).pure(), + CommandSpec::new("styles.inspectorClear", "Clear Formatting Level", "Home › Styles › Style Inspector", clear) + .params(r#"{"level": "paragraphStyle|paragraphFormatting|characterStyle|characterFormatting"}"#), + ] +} + +/// The paragraph props at the caret (the selection start for a selection). +fn para_props(s: &Session) -> ParaProps { + let (a, _) = s.sel.ordered(); + s.doc.para_at(&a).map(|p| p.props.clone()).unwrap_or_default() +} + +/// The direct character props at the caret (the first selected character for a selection). +fn char_props(s: &Session) -> CharProps { + let (a, b) = s.sel.ordered(); + if a == b { + return s.typing_props(); + } + match s.doc.para_at(&a) { + Some(p) if a.off < p.len() => p.props_of_char(a.off).clone(), + _ => s.typing_props(), + } +} + +/// Each set field of `direct` (minus `skip`) whose value changes the result of `resolve`, given +/// a props value holding only that field. Returns `[{"prop", "value"}]` in field order. +fn differences(direct: &T, skip: &[&str], base: &Value, resolve: &dyn Fn(T) -> Value) -> Vec { + let Ok(Value::Object(fields)) = serde_json::to_value(direct) else { return Vec::new() }; + let mut out = Vec::new(); + for (k, v) in fields { + if skip.contains(&k.as_str()) || v.is_null() { + continue; + } + let mut one = Map::new(); + one.insert(k.clone(), v.clone()); + let Ok(single) = serde_json::from_value::(Value::Object(one)) else { continue }; + if resolve(single) != *base { + out.push(json!({"prop": k, "value": v})); + } + } + out +} + +fn style_name(s: &Session, id: &str) -> String { + s.doc.styles.get(id).map(|st| st.name.clone()).unwrap_or_else(|| id.to_string()) +} + +/// What the Style Inspector shows, as JSON. +pub fn inspect(s: &Session) -> Value { + let pp = para_props(s); + let pstyle = pp.style.clone().unwrap_or_else(|| "Normal".into()); + let styles = &s.doc.styles; + let para_base = ParaProps { style: Some(pstyle.clone()), ..Default::default() }; + let pbase = serde_json::to_value(styles.resolve_para(¶_base)).unwrap_or(Value::Null); + let pdiff = differences(&pp, &PARA_SKIP, &pbase, &|mut one: ParaProps| { + one.style = Some(pstyle.clone()); + serde_json::to_value(styles.resolve_para(&one)).unwrap_or(Value::Null) + }); + + let cp = char_props(s); + let cstyle = cp.style.clone(); + let char_base = CharProps { style: cstyle.clone(), ..Default::default() }; + let cbase = serde_json::to_value(styles.resolve_char(Some(&pstyle), &char_base)).unwrap_or(Value::Null); + let cdiff = differences(&cp, &CHAR_SKIP, &cbase, &|mut one: CharProps| { + one.style = cstyle.clone(); + serde_json::to_value(styles.resolve_char(Some(&pstyle), &one)).unwrap_or(Value::Null) + }); + + json!({ + "paragraph": { + "style": pstyle, + "styleName": style_name(s, &pstyle), + "direct": pdiff, + }, + "character": { + "style": cstyle, + "styleName": cstyle.as_deref().map(|c| style_name(s, c)), + "direct": cdiff, + }, + }) +} + +fn clear(s: &mut Session, v: &Value) -> CmdResult { + let level = p::req_str(v, "level")?; + let (a, b) = s.sel.ordered(); + match level { + "paragraphStyle" => { + // Reset to Normal, keeping direct formatting. + s.doc.format_paragraphs(&a, &b, &|p| p.style = None)?; + } + "paragraphFormatting" => { + s.doc.format_paragraphs(&a, &b, &|p| { + *p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() }; + })?; + } + "characterStyle" => return super::format::apply(s, &|c| c.style = None), + "characterFormatting" => { + return super::format::apply(s, &|c| { + *c = CharProps { + style: c.style.clone(), + link: c.link.clone(), + ins: c.ins, + del: c.del, + lang: c.lang.clone(), + no_proof: c.no_proof, + rtl: c.rtl, + ..Default::default() + }; + }); + } + other => return Err(CmdError::Params(format!("unknown level `{other}`; expected one of {}", LEVELS.join(", ")))), + } + sel_result(s) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6eefb9b98..8ce289867 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub mod equation; pub mod file; pub mod format; pub mod insert; +pub mod inspector; pub mod mailings; pub mod objects; pub mod page; @@ -37,6 +38,7 @@ pub fn registry() -> Registry { v.extend(paste::specs()); v.extend(format::specs()); v.extend(para::specs()); + v.extend(inspector::specs()); v.extend(view::specs()); v.extend(insert::specs()); v.extend(equation::specs()); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index d37454809..b32ef2819 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -48,6 +48,9 @@ pub struct ViewState { pub table_gridlines: bool, pub nav_pane: bool, pub styles_pane: bool, + /// Style Inspector pane (Home › Styles). + #[serde(default)] + pub style_inspector: bool, pub comments_pane: bool, pub multi_page: bool, /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. @@ -73,6 +76,7 @@ impl Default for ViewState { table_gridlines: false, nav_pane: false, styles_pane: false, + style_inspector: false, comments_pane: false, multi_page: false, fit: String::new(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fb50dab5e..65bbc83c3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1779,6 +1779,81 @@ fn timestamps_come_from_the_clock() { assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } +#[test] +fn style_inspector_reports_and_clears_levels() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Chapter one"})); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "para.alignCenter", json!({})); + + let r = run(&mut s, "styles.inspect", json!({})); + assert_eq!(r["paragraph"]["style"], "Heading1"); + assert_eq!(r["paragraph"]["styleName"], "Heading 1"); + let pdirect = r["paragraph"]["direct"].as_array().unwrap(); + assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}"); + assert!(r["character"]["style"].is_null()); + let cdirect = r["character"]["direct"].as_array().unwrap(); + assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]); + + // Formatting that matches the style isn't a difference (Heading 1 is 20 pt). + run(&mut s, "format.size", json!({"size": 20})); + let r = run(&mut s, "styles.inspect", json!({})); + assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}"); + + // Character style level. + run(&mut s, "format.charStyle", json!({"style": "Emphasis"})); + let r = run(&mut s, "styles.inspect", json!({})); + assert_eq!(r["character"]["style"], "Emphasis"); + assert_eq!(r["character"]["styleName"], "Emphasis"); + run(&mut s, "styles.inspectorClear", json!({"level": "characterStyle"})); + assert!(run(&mut s, "styles.inspect", json!({}))["character"]["style"].is_null()); + + // Clearing character formatting removes the bold and keeps the paragraph levels. + run(&mut s, "styles.inspectorClear", json!({"level": "characterFormatting"})); + let r = run(&mut s, "styles.inspect", json!({})); + assert!(r["character"]["direct"].as_array().unwrap().is_empty(), "{r}"); + assert!(!run(&mut s, "format.state", json!({}))["bold"].as_bool().unwrap()); + assert_eq!(r["paragraph"]["style"], "Heading1"); + assert!(!r["paragraph"]["direct"].as_array().unwrap().is_empty()); + + run(&mut s, "styles.inspectorClear", json!({"level": "paragraphFormatting"})); + let r = run(&mut s, "styles.inspect", json!({})); + assert!(r["paragraph"]["direct"].as_array().unwrap().is_empty(), "{r}"); + assert_eq!(r["paragraph"]["style"], "Heading1"); + + run(&mut s, "styles.inspectorClear", json!({"level": "paragraphStyle"})); + assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Normal"); + + // Each clear is one undo step. + run(&mut s, "edit.undo", json!({})); + assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Heading1"); + assert_eq!(text(&s), "Chapter one"); +} + +#[test] +fn style_inspector_pane_and_hostile_params() { + let mut s = Session::new(crate::sample::sample_document()); + let r = run(&mut s, "styles.inspector", json!({})); + assert_eq!(r["pane"], true); + assert!(s.view.style_inspector); + assert_eq!(r["paragraph"]["styleName"], "Title"); + let r = run(&mut s, "styles.inspector", json!({"value": false})); + assert_eq!(r["pane"], false); + let before = text(&s); + for junk in [json!(null), json!({}), json!({"level": 5}), json!({"level": "everything"}), json!({"level": ""})] { + assert!(s.run("styles.inspectorClear", &junk).is_err(), "{junk}"); + } + assert_eq!(text(&s), before); + assert!(s.run("styles.inspector", &json!({"value": "yes"})).is_ok()); + // A selection reaching far outside the document. + s.sel.focus = Pos::body(9999, 99999); + s.sel.anchor = Pos::body(0, 0); + assert!(s.run("styles.inspect", &json!({})).is_ok()); + assert!(s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).is_ok()); +} + /// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight. #[test] fn advanced_find_lists_and_highlights_matches() { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 55cd1f7fc..7f453537b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -111,6 +111,7 @@ Character Scale Escala del carácter Character Shading Sombreado de carácter Character Spacing Espaciado de caracteres + Characters Caracteres Characters (no spaces) Caracteres (sin espacios) Characters (with spaces) Caracteres (con espacios) Chart Gráfico @@ -122,7 +123,12 @@ Citation Style Estilo de cita Citations & Bibliography Citas y bibliografía Clear Borrar + Clear All Borrar todo Clear All Formatting Borrar todo el formato + Clear Character Formatting Borrar formato de carácter + Clear Character Style Borrar estilo de carácter + Clear Formatting Level Borrar nivel de formato + Clear Paragraph Formatting Borrar formato de párrafo Clipboard Portapapeles Close Cerrar Close Header and Footer Cerrar encabezado y pie de página @@ -189,6 +195,7 @@ Dictate Dictar Different First Page Primera página diferente Different Odd & Even Pages Páginas pares e impares diferentes + Direct formatting Formato directo Discord Discord Discord community Comunidad de Discord Display Mostrar @@ -346,6 +353,7 @@ Insert\nFootnote Insertar nota\nal pie Inside Borders Bordes interiores Inspect Document Inspeccionar documento + Inspect Styles at Selection Inspeccionar estilos de la selección Interface language: Idioma de la interfaz: Italic Cursiva Join the ArtCraft community on Discord Únete a la comunidad de ArtCraft en Discord @@ -570,6 +578,7 @@ Research Investigación Researcher Investigador Reset Picture Restablecer imagen + Reset to Normal Restablecer a Normal Resolve Resolver Resolved Resuelto Restart Each Page Reiniciar en cada página @@ -684,6 +693,7 @@ Studio cover Portada de Studio Studio handbook (sample) Manual de Studio (ejemplo) Style Estilo + Style Inspector Inspector de estilos Style Set Conjunto de estilos Style based on: Estilo basado en: Style: APA Estilo: APA @@ -973,3 +983,30 @@ Whole Table Toda la tabla Fill color: Color de relleno: {count} items highlighted {count} elementos resaltados + {n} pt {n} pt + Not {name} Sin {name} + Level {n} Nivel {n} + Body Text Texto independiente + {n} lines {n} líneas + At least {n} Mínimo {n} + Exactly {n} Exacto {n} + Centered Centrado + Character spacing Espaciado de caracteres + Don't add space between paragraphs of the same style No agregar espacio entre párrafos del mismo estilo + Don't hyphenate No separar con guiones + Double strikethrough Doble tachado + Drop cap Letra capital + First line Primera línea + Font color Color de fuente + Highlight Resaltado + Justified Justificado + Left indent Sangría izquierda + Line spacing Interlineado + Outline level Nivel de esquema + Raised/lowered Elevado/bajado + Right indent Sangría derecha + Scale Escala + Space after Espacio posterior + Space before Espacio anterior + Suppress line numbers Suprimir números de línea + Underline color Color de subrayado diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c985bc82a..6b9331477 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,6 +931,16 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + Style Inspector スタイル インスペクター + Inspect Styles at Selection 選択箇所のスタイルを調べる + Clear Formatting Level 書式のレベルをクリア + Characters 文字 + Direct formatting 直接の書式 + Reset to Normal 標準に戻す + Clear Paragraph Formatting 段落の書式をクリア + Clear Character Style 文字スタイルをクリア + Clear Character Formatting 文字の書式をクリア + Clear All すべてクリア Paste Special 形式を選択して貼り付け Paste Special… 形式を選択して貼り付け… Paste as: 貼り付ける形式: @@ -956,3 +966,30 @@ Whole Table 表全体 Fill color: 塗りつぶしの色: {count} items highlighted {count} 件を強調表示しました + {n} pt {n} pt + Not {name} {name} なし + Level {n} レベル {n} + Body Text 本文 + {n} lines {n} 行 + At least {n} 最小値 {n} + Exactly {n} 固定値 {n} + Centered 中央揃え + Character spacing 文字間隔 + Don't add space between paragraphs of the same style 同じスタイルの段落間に間隔を追加しない + Don't hyphenate ハイフネーションなし + Double strikethrough 二重取り消し線 + Drop cap ドロップ キャップ + First line 最初の行 + Font color フォントの色 + Highlight 蛍光ペン + Justified 両端揃え + Left indent 左インデント + Line spacing 行間 + Outline level アウトライン レベル + Raised/lowered 上げ/下げ + Right indent 右インデント + Scale 倍率 + Space after 段落後の間隔 + Space before 段落前の間隔 + Suppress line numbers 行番号を表示しない + Underline color 下線の色 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 410d82b5f..d36694ffd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,6 +943,16 @@ 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. + Style Inspector Inspetor de estilos + Inspect Styles at Selection Inspecionar estilos na seleção + Clear Formatting Level Limpar nível de formatação + Characters Caracteres + Direct formatting Formatação direta + Reset to Normal Redefinir para Normal + Clear Paragraph Formatting Limpar formatação do parágrafo + Clear Character Style Limpar estilo de caractere + Clear Character Formatting Limpar formatação de caractere + Clear All Limpar tudo Paste Special Colar especial Paste Special… Colar especial… Paste as: Colar como: @@ -968,3 +978,30 @@ Whole Table Tabela inteira Fill color: Cor de preenchimento: {count} items highlighted {count} itens realçados + {n} pt {n} pt + Not {name} Sem {name} + Level {n} Nível {n} + Body Text Corpo do texto + {n} lines {n} linhas + At least {n} Pelo menos {n} + Exactly {n} Exatamente {n} + Centered Centralizado + Character spacing Espaçamento entre caracteres + Don't add space between paragraphs of the same style Não adicionar espaço entre parágrafos do mesmo estilo + Don't hyphenate Não hifenizar + Double strikethrough Tachado duplo + Drop cap Capitular + First line Primeira linha + Font color Cor da fonte + Highlight Realce + Justified Justificado + Left indent Recuo à esquerda + Line spacing Espaçamento entre linhas + Outline level Nível de tópicos + Raised/lowered Elevado/rebaixado + Right indent Recuo à direita + Scale Escala + Space after Espaço depois + Space before Espaço antes + Suppress line numbers Suprimir números de linha + Underline color Cor do sublinhado diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f10005400..5f3b53419 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -108,6 +108,7 @@ Character Scale Масштаб символів Character Shading Заливка символів Character Spacing Інтервал між символами + Characters Символи Characters (no spaces) Символи (без пробілів) Characters (with spaces) Символи (з пробілами) Chart Діаграма @@ -119,7 +120,12 @@ Citation Style Стиль цитування Citations & Bibliography Цитати й бібліографія Clear Очистити + Clear All Очистити все Clear All Formatting Очистити все форматування + Clear Character Formatting Очистити форматування символів + Clear Character Style Очистити стиль символів + Clear Formatting Level Очистити рівень форматування + Clear Paragraph Formatting Очистити форматування абзацу Clipboard Буфер обміну Close Закрити Close Header and Footer Закрити колонтитули @@ -190,6 +196,7 @@ Dictate Диктувати Different First Page Інша перша сторінка Different Odd & Even Pages Різні парні й непарні сторінки + Direct formatting Пряме форматування Discord Discord Discord community Спільнота Discord Display Відображення @@ -348,6 +355,7 @@ Insert\nFootnote Вставити\nвиноску Inside Borders Внутрішні межі Inspect Document Перевірити документ + Inspect Styles at Selection Переглянути стилі у виділенні Interface language: Мова інтерфейсу: Italic Курсив Join the ArtCraft community on Discord Приєднатися до спільноти ArtCraft у Discord @@ -571,6 +579,7 @@ Research Дослідження Researcher Дослідник Reset Picture Скинути зображення + Reset to Normal Скинути до звичайного Resolve Вирішити Resolved Вирішено Restart at 1 Почати з 1 @@ -686,6 +695,7 @@ Studio handbook (sample) Посібник студії (приклад) Style Стиль Style based on: Стиль на основі: + Style Inspector Інспектор стилів Style Set Набір стилів Style: APA Стиль: APA Styles Стилі @@ -968,3 +978,30 @@ Whole Table Уся таблиця Fill color: Колір заливки: {count} items highlighted Виділено елементів: {count} + {n} pt {n} пт + Not {name} Без: {name} + Level {n} Рівень {n} + Body Text Основний текст + {n} lines Рядків: {n} + At least {n} Щонайменше {n} + Exactly {n} Точно {n} + Centered По центру + Character spacing Інтервал між символами + Don't add space between paragraphs of the same style Не додавати інтервал між абзацами одного стилю + Don't hyphenate Без перенесення слів + Double strikethrough Подвійне закреслення + Drop cap Буквиця + First line Перший рядок + Font color Колір шрифту + Highlight Виділення кольором + Justified За шириною + Left indent Відступ ліворуч + Line spacing Міжрядковий інтервал + Outline level Рівень плану + Raised/lowered Піднято/опущено + Right indent Відступ праворуч + Scale Масштаб + Space after Інтервал після + Space before Інтервал перед + Suppress line numbers Без номерів рядків + Underline color Колір підкреслення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4faefa189..cfea6a41a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,6 +942,16 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + Style Inspector 样式检查器 + Inspect Styles at Selection 检查所选内容的样式 + Clear Formatting Level 清除格式级别 + Characters 字符 + Direct formatting 直接格式 + Reset to Normal 重置为正文 + Clear Paragraph Formatting 清除段落格式 + Clear Character Style 清除字符样式 + Clear Character Formatting 清除字符格式 + Clear All 全部清除 Paste Special 选择性粘贴 Paste Special… 选择性粘贴… Paste as: 粘贴为: @@ -967,3 +977,30 @@ Whole Table 整个表格 Fill color: 填充颜色: {count} items highlighted 已突出显示 {count} 项 + {n} pt {n} 磅 + Not {name} 非{name} + Level {n} {n} 级 + Body Text 正文文本 + {n} lines {n} 行 + At least {n} 最小值 {n} + Exactly {n} 固定值 {n} + Centered 居中 + Character spacing 字符间距 + Don't add space between paragraphs of the same style 相同样式的段落间不添加间距 + Don't hyphenate 不断字 + Double strikethrough 双删除线 + Drop cap 首字下沉 + First line 首行 + Font color 字体颜色 + Highlight 突出显示 + Justified 两端对齐 + Left indent 左缩进 + Line spacing 行距 + Outline level 大纲级别 + Raised/lowered 提升/降低 + Right indent 右缩进 + Scale 缩放 + Space after 段后间距 + Space before 段前间距 + Suppress line numbers 取消行号 + Underline color 下划线颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e36b59080..72a7db5ff 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,6 +932,16 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + Style Inspector 樣式檢查器 + Inspect Styles at Selection 檢查選取範圍的樣式 + Clear Formatting Level 清除格式層級 + Characters 字元 + Direct formatting 直接格式設定 + Reset to Normal 重設為內文 + Clear Paragraph Formatting 清除段落格式設定 + Clear Character Style 清除字元樣式 + Clear Character Formatting 清除字元格式設定 + Clear All 全部清除 Paste Special 選擇性貼上 Paste Special… 選擇性貼上… Paste as: 貼上為: @@ -957,3 +967,30 @@ Whole Table 整個表格 Fill color: 填滿色彩: {count} items highlighted 已醒目提示 {count} 個項目 + {n} pt {n} 點 + Not {name} 非{name} + Level {n} 第 {n} 階 + Body Text 本文 + {n} lines {n} 行 + At least {n} 最小行高 {n} + Exactly {n} 固定行高 {n} + Centered 置中 + Character spacing 字元間距 + Don't add space between paragraphs of the same style 相同樣式的段落之間不新增間距 + Don't hyphenate 不斷字 + Double strikethrough 雙刪除線 + Drop cap 首字放大 + First line 第一行 + Font color 字型色彩 + Highlight 醒目提示 + Justified 左右對齊 + Left indent 左縮排 + Line spacing 行距 + Outline level 大綱階層 + Raised/lowered 上移/下移 + Right indent 右縮排 + Scale 縮放 + Space after 段落後間距 + Space before 段落前間距 + Suppress line numbers 隱藏行號 + Underline color 底線色彩 diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 071449d4e..2390b42a3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1,4 +1,4 @@ -//! Side panes: Navigation (headings, pages, search results), Styles, Comments. +//! Side panes: Navigation (headings, pages, search results), Styles, Style Inspector, Comments. use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -23,6 +23,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| styles(app, ui)); } + if app.session.view.style_inspector { + egui::Panel::right("style_inspector") + .default_size(240.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| inspector(app, ui)); + } if app.session.view.comments_pane { egui::Panel::right("comments_pane") .default_size(290.0) @@ -160,6 +167,9 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("format.clear", json!({})); } }); + if ui.selectable_label(app.session.view.style_inspector, tl!("Style Inspector")).clicked() { + let _ = app.run("styles.inspector", json!({})); + } ui.separator(); let mut list: Vec<(String, String, bool)> = app .session @@ -264,3 +274,222 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { }); let _ = vec2(0.0, 0.0); } + +/// Style Inspector: the paragraph and character levels at the caret, each with its style and the +/// direct formatting on top, and a button to clear each level. +fn inspector(app: &mut WordApp, ui: &mut Ui) { + if header(ui, "Style Inspector") { + let _ = app.run("styles.inspector", json!({"value": false})); + return; + } + // Read-only, so call it directly: running the command every frame would pay for the command + // machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane. + let r = wordcraft_engine::cmd::inspector::inspect(&app.session); + let t = Tokens::get(ui.ctx()); + let none = tl!("None").to_string(); + let para = &r["paragraph"]; + let chr = &r["character"]; + let pstyle = para["styleName"].as_str().unwrap_or("Normal").to_string(); + let cstyle = chr["styleName"].as_str().map(str::to_string); + let pdirect = direct_list(¶["direct"]); + let cdirect = direct_list(&chr["direct"]); + let mut clear: Option<&str> = None; + egui::ScrollArea::vertical().show(ui, |ui| { + section(ui, &t, "Paragraph", |ui| { + if level_row(ui, &t, "Style", &pstyle, false, "Reset to Normal", pstyle == "Normal" && para["style"] == "Normal") { + clear = Some("paragraphStyle"); + } + let (txt, empty) = if pdirect.is_empty() { (none.clone(), true) } else { (pdirect.join(", "), false) }; + if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Paragraph Formatting", empty) { + clear = Some("paragraphFormatting"); + } + }); + ui.add_space(8.0); + section(ui, &t, "Characters", |ui| { + let (txt, empty) = match &cstyle { + Some(n) => (n.clone(), false), + None => (none.clone(), true), + }; + if level_row(ui, &t, "Style", &txt, empty, "Clear Character Style", empty) { + clear = Some("characterStyle"); + } + let (txt, empty) = if cdirect.is_empty() { (none.clone(), true) } else { (cdirect.join(", "), false) }; + if level_row(ui, &t, "Direct formatting", &txt, empty, "Clear Character Formatting", empty) { + clear = Some("characterFormatting"); + } + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button(tl!("Clear All")).on_hover_text(tl!("Clear All Formatting")).clicked() { + let _ = app.run("format.clear", json!({})); + app.canvas.want_focus = true; + } + if ui.button(tl!("Styles Pane")).clicked() { + let _ = app.run("view.stylesPane", json!({"value": true})); + } + }); + }); + if let Some(level) = clear { + let _ = app.run("styles.inspectorClear", json!({"level": level})); + app.canvas.want_focus = true; + } +} + +/// A titled, bordered group in the inspector. +fn section(ui: &mut Ui, t: &Tokens, title: &str, body: impl FnOnce(&mut Ui)) { + ui.label(egui::RichText::new(tl!(title)).font(semibold(12.5)).color(t.text)); + ui.add_space(2.0); + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(8).show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui); + }); +} + +/// One level: a caption, its value, and a clear button (disabled when there's nothing to clear). +/// Returns true when the button was clicked. +fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip: &str, disabled: bool) -> bool { + ui.label(egui::RichText::new(tl!(caption)).font(regular(11.0)).color(t.text_dim)); + let mut clicked = false; + ui.horizontal(|ui| { + let w = (ui.available_width() - 26.0).max(40.0); + ui.allocate_ui(vec2(w, 18.0), |ui| { + ui.set_width(w); + let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text }); + ui.add(egui::Label::new(text).wrap()); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click()); + let enabled = !disabled; + if enabled && resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let c = if enabled { t.icon } else { t.text_disabled }; + crate::icons::paint( + ui.painter(), + egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), + "clear", + c, + if enabled { t.accent } else { c }, + ); + let resp = resp.on_hover_text(tl!(tip)); + clicked = enabled && resp.clicked(); + }); + }); + ui.add_space(4.0); + clicked +} + +/// Readable descriptions of the inspector's `direct` entries (`[{"prop", "value"}]`). +fn direct_list(v: &Value) -> Vec { + v.as_array().map(|a| a.iter().take(64).map(|d| describe(d["prop"].as_str().unwrap_or(""), &d["value"])).collect()).unwrap_or_default() +} + +/// "Bold", "Not Italic", "Font: Arial", "Size: 14 pt"… +fn describe(prop: &str, v: &Value) -> String { + let label = match prop { + "font" => "Font", + "size" => "Size", + "bold" => "Bold", + "italic" => "Italic", + "underline" => "Underline", + "underlineColor" => "Underline color", + "strike" => "Strikethrough", + "doubleStrike" => "Double strikethrough", + "color" => "Font color", + "highlight" => "Highlight", + "shading" => "Shading", + "vertAlign" => "Position", + "caps" => "All caps", + "smallCaps" => "Small caps", + "hidden" => "Hidden", + "spacing" => "Character spacing", + "scale" => "Scale", + "position" => "Raised/lowered", + "outline" => "Outline", + "shadow" => "Shadow", + "emboss" => "Emboss", + "engrave" => "Engrave", + "align" => "Alignment", + "indentLeft" => "Left indent", + "indentRight" => "Right indent", + "indentFirst" => "First line", + "spaceBefore" => "Space before", + "spaceAfter" => "Space after", + "lineSpacing" => "Line spacing", + "contextualSpacing" => "Don't add space between paragraphs of the same style", + "keepNext" => "Keep with next", + "keepLines" => "Keep lines together", + "pageBreakBefore" => "Page break before", + "widowControl" => "Widow/Orphan control", + "outlineLevel" => "Outline level", + "tabs" => "Tabs", + "borders" => "Borders", + "suppressHyphens" => "Don't hyphenate", + "suppressLineNumbers" => "Suppress line numbers", + "bidi" => "Right-to-left", + "dropCap" => "Drop cap", + other => other, + }; + let label = tl!(label); + let pt = |x: f64| crate::i18n::fmt(tl!("{n} pt"), &[("n", &trim_num(x))]); + let hex = |c: &Value| -> Option { + let a = c.as_array()?; + let ch = |i: usize| a.get(i).and_then(Value::as_u64).unwrap_or(0).min(255); + Some(format!("#{:02X}{:02X}{:02X}", ch(0), ch(1), ch(2))) + }; + let value = match (prop, v) { + (_, Value::Bool(true)) => return label.to_string(), + (_, Value::Bool(false)) => return crate::i18n::fmt(tl!("Not {name}"), &[("name", label)]), + ("scale", Value::Number(n)) => format!("{}%", trim_num(n.as_f64().unwrap_or(100.0))), + ("outlineLevel", Value::Number(n)) => match n.as_u64().unwrap_or(9) { + l @ 0..=8 => crate::i18n::fmt(tl!("Level {n}"), &[("n", &(l + 1).to_string())]), + _ => tl!("Body Text").to_string(), + }, + ("dropCap", Value::Number(n)) => crate::i18n::fmt(tl!("{n} lines"), &[("n", &n.to_string())]), + (_, Value::Number(n)) => pt(n.as_f64().unwrap_or(0.0)), + ("align", Value::String(s)) => tl!(match s.as_str() { + "center" => "Centered", + "right" => "Right", + "justify" => "Justified", + "distribute" => "Distributed", + _ => "Left", + }) + .to_string(), + ("color", Value::String(_)) => tl!("Automatic").to_string(), + ("color", Value::Object(o)) => o.get("Rgb").and_then(hex).unwrap_or_default(), + (_, Value::Array(_)) if prop != "tabs" => hex(v).unwrap_or_default(), + ("lineSpacing", Value::Object(o)) => { + let x = o.get("value").and_then(Value::as_f64).unwrap_or(1.0); + match o.get("rule").and_then(Value::as_str) { + Some("atLeast") => crate::i18n::fmt(tl!("At least {n}"), &[("n", &pt(x))]), + Some("exactly") => crate::i18n::fmt(tl!("Exactly {n}"), &[("n", &pt(x))]), + _ => crate::i18n::fmt(tl!("{n} lines"), &[("n", &trim_num(x))]), + } + } + (_, Value::String(s)) => tl!(&camel_words(s)).to_string(), + _ => return label.to_string(), + }; + format!("{label}: {value}") +} + +/// `1.50` → `1.5`, `12.0` → `12`. +fn trim_num(x: f64) -> String { + let s = format!("{:.2}", if x.is_finite() { x } else { 0.0 }); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +/// `dotDash` → `Dot dash` (enum values from the document model). +fn camel_words(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + for (i, c) in s.chars().take(64).enumerate() { + if i == 0 { + out.extend(c.to_uppercase()); + } else if c.is_uppercase() { + out.push(' '); + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} diff --git a/docs/parity.md b/docs/parity.md index a227f82b4..d72f8c35c 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. -**382 of 430 catalog features have commands (88.8%).** +**383 of 430 catalog features have commands (89.1%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 94 | 101 | +| Home | 95 | 101 | | Insert | 42 | 53 | | Draw | 0 | 11 | | Design | 10 | 10 | @@ -34,7 +34,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Font › Phonetic Guide (`format.phonetic`) - Font › Enclose Characters (`format.enclose`) - Paragraph › Asian Typography (`para.asianTypography`) -- Styles › Style Inspector (`styles.inspector`) - Styles › Manage Styles (`styles.manage`) - Voice › Dictate (`tools.dictate`)