Style Inspector: styles.inspector pane, styles.inspect query and per-level clearing (#236)

styles.inspector shows/hides the Style Inspector pane and returns the
paragraph style, character style and the direct formatting that differs
from each; styles.inspect is the pure query; styles.inspectorClear
resets one level (paragraphStyle, paragraphFormatting, characterStyle,
characterFormatting) as a single undo step. The pane opens from the
Styles pane, with a clear button per level. Parity floor raised to 87%.

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 13:46:39 -04:00
committed by GitHub
co-authored by Dmitrii Khlapov Claude Opus 5.5 Brandon Thomas
parent 8bc4e6c999
commit e71c2b6c5b
13 changed files with 682 additions and 6 deletions
+2 -2
View File
@@ -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) |
+145
View File
@@ -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<CommandSpec> {
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<T: Serialize + DeserializeOwned>(direct: &T, skip: &[&str], base: &Value, resolve: &dyn Fn(T) -> Value) -> Vec<Value> {
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::<T>(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(&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)
}
+2
View File
@@ -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());
+4
View File
@@ -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(),
+75
View File
@@ -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() {
+37
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+37
View File
@@ -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 下線の色
Can't render this file because it contains an unexpected character in line 411 and column 13.
+37
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+37
View File
@@ -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 Колір підкреслення
Can't render this file because it contains an unexpected character in line 6 and column 31.
+37
View File
@@ -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 下划线颜色
Can't render this file because it contains an unexpected character in line 6 and column 25.
+37
View File
@@ -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 底線色彩
Can't render this file because it contains an unexpected character in line 412 and column 13.
+230 -1
View File
@@ -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(&para["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<String> {
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<String> {
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
}
+2 -3
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.
**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`)