From 7a44b180720311f95f94ea61586e549344308741 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:21:41 +0200 Subject: [PATCH 01/16] Clipboard pane: collect the last 24 copies and cuts and paste any of them again Copy and Cut now also keep each item (rich fragment plus plain text) in a session list of up to 24, newest first. Items over ~4 MB (estimated) are skipped and the list stays under ~24 MB in total; copying the same thing twice in a row adds it once. The system clipboard is untouched. New commands: edit.clipboardPane (toggle the pane, also the Clipboard group's dialog launcher), edit.clipboardItems (list with previews), edit.pasteClipboardItem {index}, edit.pasteAllClipboard, edit.deleteClipboardItem {index} and edit.clearClipboard. Pasting goes through the normal paste path, so it is one undo step and respects Track Changes. The pane lists the items with a short preview; clicking one pastes it at the caret, and each has Delete, plus Paste All and Clear All. Parity: 378 -> 379 of 428 features. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 160 ++++++++++++++++++++++++++++ crates/engine/src/session.rs | 8 ++ crates/engine/src/tests.rs | 67 ++++++++++++ crates/ui-egui/src/i18n/es.tsv | 9 ++ crates/ui-egui/src/i18n/ja.tsv | 9 ++ crates/ui-egui/src/i18n/pt-br.tsv | 9 ++ crates/ui-egui/src/i18n/uk.tsv | 9 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 9 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 9 ++ crates/ui-egui/src/panes.rs | 54 +++++++++- crates/ui-egui/src/ribbon.rs | 2 +- docs/parity.md | 5 +- 12 files changed, 345 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1a86a758c..ca5d53204 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -32,6 +32,31 @@ pub fn specs() -> Vec { .params(r#"{"text"?: string}"#) .key("Mod+Shift+Alt+V"), CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#), + CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| { + let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane); + s.view.clipboard_pane = on; + Ok(json!({"value": on, "count": s.clip_history.len()})) + }) + .params(r#"{"value"?: bool}"#) + .pure(), + CommandSpec::new("edit.clipboardItems", "Clipboard Items", "Home › Clipboard › Clipboard Pane", |s, _| Ok(s.clip_history.describe())).pure(), + CommandSpec::new("edit.pasteClipboardItem", "Paste Clipboard Item", "Home › Clipboard › Clipboard Pane", paste_clip_item) + .params(r#"{"index": n (0 = newest)}"#) + .when(has_clips), + CommandSpec::new("edit.pasteAllClipboard", "Paste All", "Home › Clipboard › Clipboard Pane", paste_all_clips).when(has_clips), + CommandSpec::new("edit.deleteClipboardItem", "Delete Clipboard Item", "Home › Clipboard › Clipboard Pane", |s, v| { + let i = clip_index(s, v)?; + s.clip_history.items.remove(i); + Ok(s.clip_history.describe()) + }) + .params(r#"{"index": n (0 = newest)}"#) + .when(has_clips) + .pure(), + CommandSpec::new("edit.clearClipboard", "Clear All", "Home › Clipboard › Clipboard Pane", |s, _| { + s.clip_history.items.clear(); + Ok(s.clip_history.describe()) + }) + .pure(), CommandSpec::new("edit.find", "Find", "Home › Editing", find) .key("Mod+F") .params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) @@ -63,6 +88,7 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult { } let f = s.doc.copy_range(&a, &b); s.clipboard_text = f.plain_text(); + s.clip_history.push(&f, &s.clipboard_text); s.clipboard = Some(f); Ok(json!({"text": s.clipboard_text})) } @@ -101,6 +127,140 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// Most items the Clipboard pane keeps. +pub const CLIP_MAX_ITEMS: usize = 24; +/// A copy bigger than this (estimated) is not collected (it still reaches the clipboard). +const CLIP_ITEM_BYTES: usize = 4 << 20; +/// All collected items together stay under this; the oldest go first. +const CLIP_TOTAL_BYTES: usize = 24 << 20; +/// Characters of preview per item. +const CLIP_PREVIEW_CHARS: usize = 120; + +/// One item collected by Copy or Cut. +#[derive(Clone, Debug)] +pub struct ClipItem { + pub fragment: Fragment, + /// Its plain text. + pub text: String, + /// Rough memory use. + bytes: usize, +} + +impl ClipItem { + /// One line of the item's text for a list: whitespace runs become one space, objects drop out. + pub fn preview(&self) -> String { + let words = self.text.split(|c: char| c.is_whitespace() || c == wordcraft_doc::para::OBJ).filter(|w| !w.is_empty()); + let mut out = String::new(); + let mut n = 0; + for w in words { + if !out.is_empty() { + out.push(' '); + n += 1; + } + for c in w.chars() { + if n >= CLIP_PREVIEW_CHARS { + out.push('…'); + return out; + } + out.push(c); + n += 1; + } + } + out + } +} + +/// Items collected by Copy and Cut during the session, newest first: the Clipboard pane +/// (Home › Clipboard). At most [`CLIP_MAX_ITEMS`]; oversized copies are skipped. +#[derive(Clone, Debug, Default)] +pub struct ClipHistory { + items: Vec, +} + +impl ClipHistory { + pub fn items(&self) -> &[ClipItem] { + &self.items + } + pub fn len(&self) -> usize { + self.items.len() + } + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + fn push(&mut self, f: &Fragment, text: &str) { + // Text plus per-paragraph overhead; pictures are references into the document. + let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1); + let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256)); + if bytes > CLIP_ITEM_BYTES || f.blocks.is_empty() { + return; + } + // Copying the same thing again doesn't add a second item. + if self.items.first().is_some_and(|i| i.text == text && i.fragment == *f) { + return; + } + self.items.insert(0, ClipItem { fragment: f.clone(), text: text.to_string(), bytes }); + self.items.truncate(CLIP_MAX_ITEMS); + while self.items.len() > 1 && self.items.iter().map(|i| i.bytes).sum::() > CLIP_TOTAL_BYTES { + self.items.pop(); + } + } + + /// The items for agents and the pane, newest first. + fn describe(&self) -> Value { + Value::Array( + self.items + .iter() + .enumerate() + .map(|(i, it)| { + json!({ + "index": i, + "preview": it.preview(), + "chars": it.text.chars().count(), + "objects": it.text.matches(wordcraft_doc::para::OBJ).count(), + }) + }) + .collect(), + ) + } +} + +fn has_clips(s: &Session) -> Option<&'static str> { + if s.clip_history.is_empty() { Some("the Clipboard pane is empty") } else { None } +} + +fn clip_index(s: &Session, v: &Value) -> Result { + let i = p::u64(v, "index").ok_or_else(|| CmdError::Params("`index` (number, 0 = newest) is required".into()))?; + usize::try_from(i) + .ok() + .filter(|i| *i < s.clip_history.len()) + .ok_or_else(|| CmdError::Params(format!("no clipboard item {i} (there are {})", s.clip_history.len()))) +} + +/// Paste a collected item through the normal paste path. The clipboard itself stays as it was. +fn paste_clip(s: &mut Session, f: Fragment) -> CmdResult { + let saved = s.clipboard.replace(f); + let r = paste(s, &json!({})); + s.clipboard = saved; + r +} + +fn paste_clip_item(s: &mut Session, v: &Value) -> CmdResult { + let i = clip_index(s, v)?; + let f = s.clip_history.items.get(i).map(|x| x.fragment.clone()).ok_or_else(|| CmdError::Params("no such clipboard item".into()))?; + paste_clip(s, f) +} + +/// Every collected item, in the order they were copied (one undo step). +fn paste_all_clips(s: &mut Session, _: &Value) -> CmdResult { + let all: Vec = s.clip_history.items.iter().rev().map(|x| x.fragment.clone()).collect(); + let mut r = sel_result(s)?; + for f in all { + r = paste_clip(s, f)?; + } + Ok(r) +} + fn paste_text(s: &mut Session, v: &Value) -> CmdResult { let text = match p::str(v, "text") { Some(t) => t.to_string(), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f779da505..5848c1c38 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -45,6 +45,9 @@ pub struct ViewState { pub nav_pane: bool, pub styles_pane: bool, pub comments_pane: bool, + /// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut. + #[serde(default)] + pub clipboard_pane: bool, pub multi_page: bool, /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. pub fit: String, @@ -69,6 +72,7 @@ impl Default for ViewState { nav_pane: false, styles_pane: false, comments_pane: false, + clipboard_pane: false, multi_page: false, fit: String::new(), web_width: 800.0, @@ -152,6 +156,8 @@ pub struct Session { pub clipboard: Option, /// Plain text mirror of the clipboard (for the system clipboard). pub clipboard_text: String, + /// Items collected by Copy and Cut this session, for the Clipboard pane. + pub clip_history: crate::cmd::edit::ClipHistory, pub find: FindState, /// Page x the caret tries to keep on Up/Down. pub goal_x: Option, @@ -250,6 +256,7 @@ impl Session { dirty: false, clipboard: None, clipboard_text: String::new(), + clip_history: Default::default(), find: FindState::default(), goal_x: None, page_hint: 0, @@ -447,6 +454,7 @@ impl Session { dirty, clipboard: _, clipboard_text: _, + clip_history: _, find: _, goal_x: _, page_hint: _, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5df02fbc6..8925f09ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -523,6 +523,73 @@ fn clipboard_round_trip() { assert_eq!(text(&s), "plain\ntext"); } +#[test] +fn clipboard_pane_collects_copies_and_pastes_one_undoably() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "one two three"})); + for (a, b) in [(0, 3), (4, 7), (8, 13)] { + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}})); + run(&mut s, "edit.copy", json!({})); + } + // Copying the same text again adds nothing. + run(&mut s, "edit.copy", json!({})); + let items = run(&mut s, "edit.clipboardItems", json!({})); + let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect(); + assert_eq!(previews, ["three", "two", "one"], "newest first"); + assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true); + assert!(s.view.clipboard_pane); + + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.pasteClipboardItem", json!({"index": 1})); + assert_eq!(text(&s), "one two threetwo"); + assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "one two three"); + + run(&mut s, "edit.pasteAllClipboard", json!({})); + assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "one two three", "Paste All is one undo step"); + + let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0})); + assert_eq!(left[0]["preview"], "two"); + run(&mut s, "edit.clearClipboard", json!({})); + assert!(s.clip_history.is_empty()); + assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err()); +} + +#[test] +fn clipboard_pane_is_capped_and_rejects_bad_indexes() { + let mut s = s(); + let words: Vec = (0..30).map(|i| format!("w{i}")).collect(); + run(&mut s, "document.setText", json!({"text": words.join(" ")})); + // Cutting each word in turn leaves the spaces: word `off` starts at `off`. + for (off, w) in words.iter().enumerate() { + run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}})); + run(&mut s, "edit.cut", json!({})); + } + let items = run(&mut s, "edit.clipboardItems", json!({})); + let items = items.as_array().unwrap(); + assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS); + assert_eq!(items[0]["preview"], "w29"); + assert_eq!(items[23]["preview"], "w6", "the oldest drop out"); + + let before = text(&s); + for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] { + assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}"); + assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}"); + } + assert_eq!(text(&s), before); + assert_eq!(s.clip_history.len(), 24); + + // A huge copy still reaches the clipboard but is not collected. + run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)})); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text.len(), 3 << 20); + assert_eq!(s.clip_history.items()[0].text, "w29"); +} + #[test] fn tables_commands() { let mut s = s(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4858ea7cd..d1b3c0ed7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -122,6 +122,15 @@ Clear Borrar Clear All Formatting Borrar todo el formato Clipboard Portapapeles + Clipboard Pane Panel del portapapeles + Clipboard Items Elementos del portapapeles + Paste Clipboard Item Pegar elemento del portapapeles + Paste All Pegar todo + Delete Clipboard Item Eliminar elemento del portapapeles + Clear All Borrar todo + Click an item to paste it. Haz clic en un elemento para pegarlo. + Nothing collected yet. Items you copy or cut appear here. Aún no hay nada. Lo que copies o cortes aparecerá aquí. + (picture or object) (imagen u objeto) Close Cerrar Close Header and Footer Cerrar encabezado y pie de página Collapse Selection Contraer selección diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dc5999fea..d9435d168 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -114,6 +114,15 @@ Clear クリア Clear All Formatting すべての書式をクリア Clipboard クリップボード + Clipboard Pane クリップボード ウィンドウ + Clipboard Items クリップボードの項目 + Paste Clipboard Item クリップボードの項目を貼り付け + Paste All すべて貼り付け + Delete Clipboard Item クリップボードの項目を削除 + Clear All すべてクリア + Click an item to paste it. 項目をクリックすると貼り付けます。 + Nothing collected yet. Items you copy or cut appear here. まだ何もありません。コピーまたは切り取った項目がここに表示されます。 + (picture or object) (画像またはオブジェクト) Close 閉じる Close Header and Footer ヘッダーとフッターを閉じる Collapse Selection 選択範囲を縮小 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c6c6bea1a..f0c7509cd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -119,6 +119,15 @@ Clear Limpar Clear All Formatting Limpar toda a formatação Clipboard Área de transferência + Clipboard Pane Painel da área de transferência + Clipboard Items Itens da área de transferência + Paste Clipboard Item Colar item da área de transferência + Paste All Colar tudo + Delete Clipboard Item Excluir item da área de transferência + Clear All Limpar tudo + Click an item to paste it. Clique em um item para colá-lo. + Nothing collected yet. Items you copy or cut appear here. Nada coletado ainda. Os itens que você copiar ou recortar aparecem aqui. + (picture or object) (imagem ou objeto) Close Fechar Close Header and Footer Fechar cabeçalho e rodapé Collapse Selection Recolher seleção diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f356a6325..bcf745b40 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -119,6 +119,15 @@ Clear Очистити Clear All Formatting Очистити все форматування Clipboard Буфер обміну + Clipboard Pane Панель буфера обміну + Clipboard Items Елементи буфера обміну + Paste Clipboard Item Вставити елемент буфера обміну + Paste All Вставити все + Delete Clipboard Item Видалити елемент буфера обміну + Clear All Очистити все + Click an item to paste it. Клацніть елемент, щоб вставити його. + Nothing collected yet. Items you copy or cut appear here. Поки що порожньо. Тут з'являться елементи, які ви скопіюєте або виріжете. + (picture or object) (зображення або об'єкт) Close Закрити Close Header and Footer Закрити колонтитули Collapse Selection Зняти виділення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 01154be08..0492132f4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -125,6 +125,15 @@ Clear 清除 Clear All Formatting 清除所有格式 Clipboard 剪贴板 + Clipboard Pane 剪贴板窗格 + Clipboard Items 剪贴板项目 + Paste Clipboard Item 粘贴剪贴板项目 + Paste All 全部粘贴 + Delete Clipboard Item 删除剪贴板项目 + Clear All 全部清除 + Click an item to paste it. 单击项目即可粘贴。 + Nothing collected yet. Items you copy or cut appear here. 尚无内容。复制或剪切的项目会显示在这里。 + (picture or object) (图片或对象) Close 关闭 Close Header and Footer 关闭页眉和页脚 Collapse Selection 折叠选定内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1e6e2bd6..07b18822f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -115,6 +115,15 @@ Clear 清除 Clear All Formatting 清除所有格式設定 Clipboard 剪貼簿 + Clipboard Pane 剪貼簿窗格 + Clipboard Items 剪貼簿項目 + Paste Clipboard Item 貼上剪貼簿項目 + Paste All 全部貼上 + Delete Clipboard Item 刪除剪貼簿項目 + Clear All 全部清除 + Click an item to paste it. 按一下項目即可貼上。 + Nothing collected yet. Items you copy or cut appear here. 尚無內容。複製或剪下的項目會顯示在這裡。 + (picture or object) (圖片或物件) Close 關閉 Close Header and Footer 關閉頁首及頁尾 Collapse Selection 摺疊選取範圍 diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 071449d4e..a50251ed5 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), Clipboard, Styles, Comments. use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -16,6 +16,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| nav(app, ui)); } + if app.session.view.clipboard_pane { + egui::Panel::left("clipboard_pane") + .default_size(250.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| clipboard(app, ui)); + } if app.session.view.styles_pane { egui::Panel::right("styles_pane") .default_size(250.0) @@ -138,6 +145,51 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { }); } +/// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. +fn clipboard(app: &mut WordApp, ui: &mut Ui) { + if header(ui, "Clipboard") { + let _ = app.run("edit.clipboardPane", json!({"value": false})); + return; + } + let items: Vec = app.session.clip_history.items().iter().map(|i| i.preview()).collect(); + ui.horizontal(|ui| { + if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() { + let _ = app.run("edit.pasteAllClipboard", json!({})); + app.canvas.want_focus = true; + } + if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() { + let _ = app.run("edit.clearClipboard", json!({})); + } + }); + ui.add_space(4.0); + let hint = if items.is_empty() { "Nothing collected yet. Items you copy or cut appear here." } else { "Click an item to paste it." }; + ui.label(egui::RichText::new(tl!(hint)).small().weak()); + ui.separator(); + let t = Tokens::get(ui.ctx()); + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, preview) in items.iter().enumerate() { + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| { + ui.set_width(ui.available_width()); + let text = if preview.is_empty() { tl!("(picture or object)").to_string() } else { preview.clone() }; + let mut job = + egui::text::LayoutJob::single_section(text, egui::TextFormat { font_id: regular(12.5), color: t.text, ..Default::default() }); + job.wrap.max_width = ui.available_width(); + job.wrap.max_rows = 3; + if ui.add(egui::Button::new(job).frame(false)).on_hover_text(tl!("Paste")).clicked() { + let _ = app.run("edit.pasteClipboardItem", json!({"index": i})); + app.canvas.want_focus = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if ui.small_button(tl!("Delete")).clicked() { + let _ = app.run("edit.deleteClipboardItem", json!({"index": i})); + } + }); + }); + ui.add_space(6.0); + } + }); +} + fn styles(app: &mut WordApp, ui: &mut Ui) { if header(ui, "Styles") { let _ = app.run("view.stylesPane", json!({"value": false})); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fcf9dedae..e4df80440 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -228,7 +228,7 @@ fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) { fn home(app: &mut WordApp, ui: &mut Ui) { let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - group(ui, "Clipboard", None, app, |ui, app| { + group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| { menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| { mi(ui, app, "Paste", "edit.paste", json!({})); mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); diff --git a/docs/parity.md b/docs/parity.md index afb626964..fdd7e43af 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. -**378 of 428 catalog features have commands (88.3%).** +**379 of 428 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 | @@ -31,7 +31,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Home - Clipboard › Paste Special (`edit.pasteSpecial`) -- Clipboard › Clipboard Pane (`edit.clipboardPane`) - Font › Phonetic Guide (`format.phonetic`) - Font › Enclose Characters (`format.enclose`) - Paragraph › Asian Typography (`para.asianTypography`) From 8aa4a25245f2738b2926c0c205ebadf05d59f051 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:30:36 +0200 Subject: [PATCH 02/16] Create and modify your own table styles, saved in the .docx (#146) New Table Style and Modify Table Style (Table Design > Table Styles) make real table styles in the document's style sheet: name, based on, and formatting for the whole table, the header row and banded rows (borders, fill, bold, font colour). The table at the caret takes the new style, it shows in the gallery, and other tools that read table styles by name (pandoc's custom-style, for one) see it. - engine: table.newStyle / table.modifyStyle with documented params (regions take borders, fill, bold, italic, colour, size, font; bandSize; para); table.style only accepts table styles now. - doc/layout: table styles gain header-row and row-band cell borders, row-band character formatting and rows per band, applied by layout. - docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are read and written, so custom styles round-trip. - ui: a compact dialog from the Styles menu next to the gallery; table style tiles redraw when a style changes. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/styles.rs | 28 ++-- crates/docx/src/read/mod.rs | 11 +- crates/docx/src/write/mod.rs | 60 ++++---- crates/docx/tests/roundtrip.rs | 8 +- crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/table.rs | 6 +- crates/engine/src/cmd/table_style.rs | 183 +++++++++++++++++++++++ crates/engine/src/tests.rs | 63 ++++++++ crates/layout/src/table.rs | 37 +++-- crates/ui-egui/src/dialogs.rs | 212 ++++++++++++++++++++++++++- crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/previews.rs | 4 +- crates/ui-egui/src/ribbon.rs | 16 ++ docs/parity.md | 4 +- 20 files changed, 620 insertions(+), 58 deletions(-) create mode 100644 crates/engine/src/cmd/table_style.rs diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 7a0e201c6..0844c9315 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -52,7 +52,16 @@ pub struct TableStyleParts { pub cell_margins: Option<[f32; 4]>, pub header_fill: Option, pub header_chr: CharProps, + /// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the + /// border between header rows, `inside_v` between its cells. + pub header_borders: Option, pub band_fill: Option, + /// Character formatting of the odd row bands (`band1Horz` region). + pub band_chr: CharProps, + /// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`). + pub band_borders: Option, + /// Rows per band (`w:tblStyleRowBandSize`); one when unset. + pub band_size: Option, pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, @@ -62,17 +71,18 @@ impl TableStyleParts { /// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins, /// the rest is inherited; borders merge edge by edge. pub fn overlay(&mut self, patch: &TableStyleParts) { - self.borders = match (self.borders, patch.borders) { - (Some(b), Some(p)) => Some(Borders { - top: p.top.or(b.top), - left: p.left.or(b.left), - bottom: p.bottom.or(b.bottom), - right: p.right.or(b.right), - between: p.between.or(b.between), - inside_v: p.inside_v.or(b.inside_v), - }), + let merge = |b: Option, p: Option| match (b, p) { + (Some(mut b), Some(p)) => { + b.overlay(&p); + Some(b) + } (b, p) => p.or(b), }; + self.borders = merge(self.borders, patch.borders); + self.header_borders = merge(self.header_borders, patch.header_borders); + self.band_borders = merge(self.band_borders, patch.band_borders); + self.band_size = patch.band_size.or(self.band_size); + self.band_chr.overlay(&patch.band_chr); self.fill = patch.fill.or(self.fill); self.cell_margins = patch.cell_margins.or(self.cell_margins); self.header_fill = patch.header_fill.or(self.header_fill); diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index d3e0b448e..bf8e29bab 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -444,9 +444,11 @@ impl Reader<'_> { } t.fill = tp.child("w:shd").and_then(props::shd_fill); t.cell_margins = tp.child("w:tblCellMar").map(props::margins); + t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000)); } - for c in s.children("w:tblStylePr") { + for c in s.children("w:tblStylePr").take(64) { let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill); + let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders); let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(); match c.attr("w:type") { Some("wholeTable") => { @@ -464,8 +466,13 @@ impl Reader<'_> { Some("firstRow") => { t.header_fill = fill; t.header_chr = chr; + t.header_borders = cell_borders; + } + Some("band1Horz") => { + t.band_fill = fill; + t.band_chr = chr; + t.band_borders = cell_borders; } - Some("band1Horz") => t.band_fill = fill, Some("firstCol") => t.first_col_chr = chr, Some("lastRow") => { t.total_chr = chr; diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 02f4f1eb5..e6a49c66e 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) { w.close("w:rPr"); } if let Some(t) = &st.table { - if t.borders.is_some() || t.cell_margins.is_some() { + if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() { w.open("w:tblPr", &[]); + if let Some(n) = t.band_size { + w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string()); + } if let Some(b) = &t.borders { props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]); } @@ -632,35 +635,40 @@ fn style_xml(w: &mut W, st: &Style) { } w.close("w:tblPr"); } - let cond = - |w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option, top: Option<&wordcraft_doc::props::Border>| { - if !props::has_rpr(chr) && fill.is_none() && top.is_none() { - return; + // One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders + // and shading. + let cond = |w: &mut W, + ty: &str, + chr: &wordcraft_doc::CharProps, + fill: Option, + borders: Option| { + if !props::has_rpr(chr) && fill.is_none() && borders.is_none() { + return; + } + w.open("w:tblStylePr", &[("w:type", ty)]); + if props::has_rpr(chr) { + w.open("w:rPr", &[]); + props::rpr_inner(w, chr); + w.close("w:rPr"); + } + if fill.is_some() || borders.is_some() { + w.open("w:tcPr", &[]); + if let Some(bs) = &borders { + props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]); } - w.open("w:tblStylePr", &[("w:type", ty)]); - if props::has_rpr(chr) { - w.open("w:rPr", &[]); - props::rpr_inner(w, chr); - w.close("w:rPr"); + if let Some(f) = fill { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); } - if fill.is_some() || top.is_some() { - w.open("w:tcPr", &[]); - if let Some(b) = top { - let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() }; - props::borders(w, "w:tcBorders", &bs, None, &[]); - } - if let Some(f) = fill { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); - } - w.close("w:tcPr"); - } - w.close("w:tblStylePr"); - }; + w.close("w:tcPr"); + } + w.close("w:tblStylePr"); + }; cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None); - cond(w, "firstRow", &t.header_chr, t.header_fill, None); - cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref()); + cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders); + let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }); + cond(w, "lastRow", &t.total_chr, None, total_top); cond(w, "firstCol", &t.first_col_chr, None, None); - cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None); + cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders); } w.close("w:style"); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 44e7715e9..e65f21b39 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -429,7 +429,8 @@ fn comments_round_trip() { } } -/// A table style's cell text formatting, whole-table shading and cell margins survive a save. +/// A table style's cell text formatting, whole-table shading, cell margins and its header row and +/// row band conditional formats (#146) survive a save. #[test] fn table_style_formatting_round_trips() { use wordcraft_doc::styles::TableStyleParts; @@ -440,6 +441,11 @@ fn table_style_formatting_round_trips() { cell_margins: Some([1.0, 14.4, 0.0, 14.4]), header_chr: CharProps { italic: Some(true), ..Default::default() }, header_fill: Some(Rgb(0xFF, 0xFF, 0)), + header_borders: Some(Borders::all(Border::single(1.5))), + band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)), + band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() }, + band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + band_size: Some(3), ..Default::default() }; d.styles.upsert(Style { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index adbd11d26..f5c881297 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -327,6 +327,8 @@ View|Macros|Record Macro|tools.recordMacro View|SharePoint|Properties|file.properties Table Design|Table Style Options|Table Style Options|table.look Table Design|Table Styles|Table Styles|table.style +Table Design|Table Styles|New Table Style|table.newStyle +Table Design|Table Styles|Modify Table Style|table.modifyStyle Table Design|Table Styles|Shading|table.shading Table Design|Borders|Borders|table.borders Table Design|Borders|Border Painter|table.borderPainter diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bd1962a55..42aaf7bc2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod references; pub mod review; pub mod speech; pub mod table; +pub mod table_style; pub mod text; pub mod tools; pub mod view; @@ -39,6 +40,7 @@ pub fn registry() -> Registry { v.extend(equation::specs()); v.extend(page::specs()); v.extend(table::specs()); + v.extend(table_style::specs()); v.extend(review::specs()); v.extend(file::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 837c032b8..7a6a408f9 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)), t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| { let st = p::req_str(v, "style")?; - let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; + let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; with_table(s, |t| t.props.style = Some(id.clone())) }) .params(r#"{"style": string}"#)), @@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul sel_result(s) } -/// Bump the revision of every paragraph in a table (table style changes alter their layout). /// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the /// caret cell's direction (horizontal → down → up) and gives every selected cell the result. fn text_direction(s: &mut Session, v: &Value) -> CmdResult { @@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult { with_cells(s, |cl| cl.props.text_direction = dir) } -fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { +/// Bump the revision of every paragraph in a table (table style changes alter their layout). +pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { let story = s.sel.focus.story; let paths: Vec = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect(); for p in paths { diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs new file mode 100644 index 000000000..79baae174 --- /dev/null +++ b/crates/engine/src/cmd/table_style.rs @@ -0,0 +1,183 @@ +//! Table Design › Table Styles: create and modify table styles (whole table, header row and +//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6). + +use serde_json::{Value, json}; +use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor}; +use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts}; + +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, +/// `bandedRows`) takes the same shortcuts. +macro_rules! style_params { + ($head:literal) => { + concat!( + "{", + $head, + r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#, + r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"# + ) + }; +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)), + CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style) + .params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)), + ] +} + +/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count. +pub fn table_style_id(s: &Session, name: &str) -> Option { + let st = &s.doc.styles.styles; + st.iter() + .find(|x| x.kind == StyleKind::Table && x.id == name) + .or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name))) + .map(|x| x.id.clone()) +} + +/// The style of the table at the caret. +fn current_table_style(s: &Session) -> Option { + let (tp, _, _) = s.sel.focus.path.cell()?; + s.doc.table(s.sel.focus.story, &tp)?.props.style.clone() +} + +fn new_style(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() { + return Err(CmdError::Params("name is empty".into())); + } + if s.doc.styles.find(&name).is_some() { + return Err(CmdError::Failed(format!("a style named `{name}` already exists"))); + } + let based_on = match p::str(v, "basedOn") { + Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?), + None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")), + }; + let id = s.doc.styles.new_id(&name); + let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() }; + edit(&mut st, v)?; + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(true) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +fn modify_style(s: &mut Session, v: &Value) -> CmdResult { + let id = match p::str(v, "style") { + Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?, + None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?, + }; + let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if let Some(n) = &rename + && s.doc.styles.find(n).is_some_and(|x| x.id != id) + { + return Err(CmdError::Failed(format!("a style named `{n}` already exists"))); + } + let based_on = match p::str(v, "basedOn") { + Some(b) => { + let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?; + // A style can't be based on itself or on a style based on it. + if s.doc.styles.chain(&b).iter().any(|x| x.id == id) { + return Err(CmdError::Params("a style can't be based on itself".into())); + } + Some(b) + } + None => None, + }; + let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?; + if let Some(n) = rename { + st.name = n; + } + if let Some(b) = based_on { + st.based_on = Some(b); + } + edit(&mut st, v)?; + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(false) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +/// Apply style `id` to the table at the caret, if any. +fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> { + let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) }; + s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string()); + super::table::touch_cells(s, &tp) +} + +/// Apply the formatting params to `st`. +fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { + let mut parts = st.table.take().unwrap_or_default(); + if let Some(r) = v.get("wholeTable") { + region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?; + } + if let Some(r) = v.get("headerRow") { + region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?; + } + if let Some(r) = v.get("bandedRows") { + region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?; + } + if let Some(n) = v.get("bandSize") { + parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32); + } + if let Some(pp) = v.get("para") { + let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?; + st.para.overlay(&pp); + st.para.style = None; + st.para.numbering = None; + } + st.table = (parts != TableStyleParts::default()).then_some(parts); + Ok(()) +} + +/// One region's shortcuts: borders, fill and character formatting. +fn region(r: &Value, fill: &mut Option, chr: &mut CharProps, borders: &mut Option) -> Result<(), CmdError> { + if !r.is_object() { + return Err(CmdError::Params("a region is an object".into())); + } + let color = |k: &str| -> Result, CmdError> { + match r.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))), + Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))), + } + }; + if r.get("fill").is_some() { + *fill = color("fill")?; + } + if r.get("color").is_some() { + chr.color = color("color")?.map(TextColor::Rgb); + } + if let Some(b) = p::bool(r, "bold") { + chr.bold = Some(b); + chr.bold_cs = Some(b); + } + if let Some(b) = p::bool(r, "italic") { + chr.italic = Some(b); + chr.italic_cs = Some(b); + } + if let Some(sz) = p::f32(r, "size") { + chr.size = Some(sz.clamp(1.0, 1638.0)); + chr.size_cs = chr.size; + } + if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) { + chr.font = Some(f.to_string()); + } + match r.get("borders") { + Some(Value::Bool(true)) => { + let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0); + *borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 })); + } + Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + Some(Value::Null) => *borders = None, + Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())), + None => {} + } + Ok(()) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d5830ad8f..ed1122940 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1700,3 +1700,66 @@ fn timestamps_come_from_the_clock() { // Fixed-width ISO strings sort by time. assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } + +/// #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] +fn custom_table_style_applies_modifies_and_saves() { + use wordcraft_doc::{Rgb, StyleKind, TextColor}; + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "Head"})); + run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + let r = run( + &mut s, + "table.newStyle", + json!({"name": "Thesis Table", "basedOn": "Table Grid", + "wholeTable": {"size": 10}, + "headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true}, + "bandedRows": {"fill": "EEEEEE", "italic": true}}), + ); + let id = r["id"].as_str().unwrap().to_string(); + let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone(); + assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table"); + assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique"); + assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles"); + assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables"); + + let fills = |s: &mut Session, c: Rgb| { + s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count() + }; + let head = |s: &mut Session| { + let mut p = tp.0.clone(); + p.extend([0, 0, 0]); + s.layout().pages[0] + .items + .iter() + .find_map( + |i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None }, + ) + .unwrap() + }; + assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells"); + assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band"); + let rc = head(&mut s); + assert!(rc.bold && rc.size == 10.0, "{rc:?}"); + assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE)); + + // Modify the current table's style: the table follows. + run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2})); + assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0); + assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2); + assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band"); + assert!(!head(&mut s).bold); + + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let st = back.styles.get(&id).unwrap(); + assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid"))); + let parts = st.table.as_ref().unwrap(); + assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2))); + assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true))); + assert!(parts.header_borders.is_some_and(|b| b.any_visible())); + let t = back.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle"); +} diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 491a4ef95..6b175796f 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -40,8 +40,9 @@ struct Turned { across: f32, /// List counters and equation count before the cell, to lay it out again the same way. snap: (Counters, u32), - /// Its formatting region (header row, total row, first column) and cell index in the row. - region: (bool, bool, bool), + /// Its formatting region (header row, total row, first column, row band) and cell index in + /// the row. + region: (bool, bool, bool, bool), ci: usize, /// Top margin plus top border band. top: f32, @@ -97,8 +98,9 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout { let style = table_style(ctx, t); let parts = style.as_ref().map(|s| &s.parts); - // Cell text formatting per (header row, total row, first column) region, built once per table. - let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new(); + // Cell text formatting per (header row, total row, first column, row band) region, built once + // per table. + let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new(); let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { @@ -152,6 +154,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava }; let nrows = t.rows.len(); let header_rows = t.props.look.header_row; + let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize; // First pass: lay out every cell's content. struct CellBox { items: Vec, @@ -178,7 +181,16 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut row_insets = 0.0f32; let is_header = header_rows && ri == 0; let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; - let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0; + let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2); + // The header row's or the odd band's own cell borders (conditional formatting). + let region_borders = if is_header { + parts.and_then(|p| p.header_borders) + } else if band { + parts.and_then(|p| p.band_borders) + } else { + None + } + .unwrap_or_default(); for (ci, cell) in row.cells.iter().enumerate() { let span = cell.span(); let x0 = colx.get(g).copied().unwrap_or(acc); @@ -186,7 +198,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let margins = cell.props.margins.unwrap_or(margins_def); let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; - let region = (is_header, is_total, t.props.look.first_column && g == 0); + let region = (is_header, is_total, t.props.look.first_column && g == 0, band); if let Some(p) = parts { if is_header { fill = fill.or(p.header_fill); @@ -202,6 +214,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // The whole table's formatting, then column, then row conditional formatting // (later regions win, ECMA-376 §17.7.6). let mut chr = st.chr.clone(); + if band { + chr.overlay(&st.parts.band_chr); + } if region.2 { chr.overlay(&st.parts.first_col_chr); } @@ -221,11 +236,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let edge = |own: Option, outer: bool, outer_b: Option, inner_b: Option| own.or(if outer { outer_b } else { inner_b }); let cb = cell.props.borders.unwrap_or_default(); + let rb = region_borders; + let (first_g, last_g) = (g == 0, g + span >= ncols); let mut borders = Borders { - top: edge(cb.top, ri == 0, tb.top, tb.between), - bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between), - left: edge(cb.left, g == 0, tb.left, tb.inside_v), - right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v), + top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between), + bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between), + left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v), + right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v), between: None, inside_v: None, }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9b080d9ed..5e60aa6fb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,8 +1,8 @@ //! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, -//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About, Save Changes, and the -//! mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, Match Fields and -//! Check for Errors. Every dialog ends by running a command (or shows one's result), so agents get -//! the same result without the dialog. +//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, About, +//! Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge +//! rules, Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's +//! result), so agents get the same result without the dialog. use egui::{Sense, Ui, vec2}; use serde::Serialize; @@ -101,6 +101,21 @@ pub enum Dialog { before: f32, after: f32, }, + /// Table Design › New / Modify Table Style: one formatting region at a time. + TableStyle { + /// The style being modified; `None` creates one. + id: Option, + name: String, + /// Style id. + based_on: String, + /// Index into `regions`: whole table, header row, banded rows. + region: usize, + regions: Box<[TableRegion; 3]>, + /// What the regions showed when the dialog opened (or the base style changed): only + /// changes are sent, so everything else stays inherited. + #[serde(skip)] + basis: Box<[TableRegion; 3]>, + }, Commands { query: String, }, @@ -211,6 +226,93 @@ pub fn recipient_table(fields: &[String], rows: &[Vec]) -> Result Box<[TableRegion; 3]> { + let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() }; + let p = &st.parts; + let hex = |c: Option| c.map(|c| c.hex()).unwrap_or_default(); + let text = |c: &wordcraft_doc::CharProps| match c.color { + Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(), + _ => String::new(), + }; + let lines = |b: Option| b.is_some_and(|b| b.any_visible()); + let mut header_chr = st.chr.clone(); + header_chr.overlay(&p.header_chr); + let mut band_chr = st.chr.clone(); + band_chr.overlay(&p.band_chr); + Box::new([ + TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) }, + TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) }, + TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) }, + ]) +} + +/// The style of the table at the caret, when it is a table style. +fn current_table_style(app: &WordApp) -> Option { + let s = &app.session; + let (tp, _, _) = s.sel.focus.path.cell()?; + let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?; + s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone()) +} + +/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region. +fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { + let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) }; + let mut v = json!({}); + if r.borders != basis.borders { + v["borders"] = json!(r.borders); + } + if r.fill != basis.fill { + v["fill"] = color(&r.fill); + } + if r.bold != basis.bold { + v["bold"] = json!(r.bold); + } + if r.color != basis.color { + v["color"] = color(&r.color); + } + v +} + +/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none. +fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) { + let c = wordcraft_doc::Rgb::parse(value); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + let t = Tokens::get(ui.ctx()); + match c { + Some(c) => { + ui.painter().rect_filled(r, 2.0, crate::theme::c32(c)); + } + None => { + ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim)); + } + } + ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() }; + ui.menu_button(label, |ui| { + if ui.button(tl!("No Color")).clicked() { + value.clear(); + ui.close(); + } + if let Some(hex) = crate::widgets::color_grid(ui, theme) { + *value = hex; + ui.close(); + } + }); + }); +} + impl Dialog { pub fn name(&self) -> &'static str { match self { @@ -228,6 +330,8 @@ impl Dialog { Dialog::Watermark { .. } => "watermark", Dialog::NewStyle { .. } => "newStyle", Dialog::ModifyStyle { .. } => "modifyStyle", + Dialog::TableStyle { id: None, .. } => "newTableStyle", + Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::Commands { .. } => "commands", Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", @@ -327,6 +431,20 @@ impl Dialog { "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, "watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true }, "newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() }, + "newTableStyle" => { + let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into()); + let styles = &app.session.doc.styles; + let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default(); + let regions = table_regions(app, &based_on); + Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions } + } + "modifyTableStyle" => { + let id = current_table_style(app)?; + let st = app.session.doc.styles.get(&id)?; + let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default()); + let regions = table_regions(app, &id); + Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } + } "commands" => Dialog::Commands { query: String::new() }, "about" => Dialog::About { tab: 0 }, "contributors" => Dialog::About { tab: 1 }, @@ -449,6 +567,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::Watermark { .. } => "Custom Watermark", Dialog::NewStyle { .. } => "Create New Style", Dialog::ModifyStyle { .. } => "Modify Style", + Dialog::TableStyle { id: None, .. } => "New Table Style", + Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", @@ -880,6 +1000,90 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + Dialog::TableStyle { id, name, based_on, region, regions, basis } => { + let styles: Vec<(String, String)> = app + .session + .doc + .styles + .styles + .iter() + .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref()) + .map(|s| (s.id.clone(), s.name.clone())) + .collect(); + let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); + let mut rebased = false; + egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Name:")); + ui.text_edit_singleline(name); + ui.end_row(); + ui.label(tl!("Style based on:")); + egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { + for (sid, sname) in &styles { + if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { + *based_on = sid.clone(); + rebased = true; + } + } + }); + ui.end_row(); + ui.label(tl!("Apply formatting to:")); + let names = ["Whole Table", "Header Row", "Banded Rows"]; + egui::ComboBox::from_id_salt("tstyle_region") + .width(200.0) + .selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table"))) + .show_ui(ui, |ui| { + for (i, n) in names.iter().enumerate() { + ui.selectable_value(region, i, tl!(n)); + } + }); + ui.end_row(); + }); + // A new style shows its new base's formatting; a modified one keeps its own. + if rebased && id.is_none() { + *regions = table_regions(app, based_on); + *basis = regions.clone(); + } + ui.separator(); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(r) = regions.get_mut(*region) { + ui.horizontal(|ui| { + ui.checkbox(&mut r.borders, tl!("Borders")); + ui.checkbox(&mut r.bold, tl!("Bold")); + }); + egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Fill color:")); + color_menu(ui, &theme, &mut r.fill); + ui.end_row(); + ui.label(tl!("Font color:")); + color_menu(ui, &theme, &mut r.color); + ui.end_row(); + }); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + let mut v = json!({"name": name.trim(), "basedOn": based_on}); + for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() { + if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) { + let ch = region_changes(r, b); + if ch.as_object().is_some_and(|o| !o.is_empty()) { + v[key] = ch; + } + } + } + let cmd = match id { + Some(sid) => { + v["style"] = json!(sid); + "table.modifyStyle" + } + None => "table.newStyle", + }; + if let Err(e) = app.run(cmd, v) { + app.status(e); + return false; + } + } + ok || cancel + } Dialog::Commands { query } => { let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0)); r.request_focus(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ef46ecfb..19de3af08 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -948,3 +948,10 @@ Dark Oscuro Use system setting Usar la configuración del sistema A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto. + New Table Style Nuevo estilo de tabla + Modify Table Style Modificar estilo de tabla + New Table Style… Nuevo estilo de tabla… + Modify Table Style… Modificar estilo de tabla… + Apply formatting to: Aplicar formato a: + Whole Table Toda la tabla + Fill color: Color de relleno: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a73def00d..4acca7de1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,3 +931,10 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + New Table Style 新しい表スタイル + Modify Table Style 表スタイルの変更 + New Table Style… 新しい表スタイル… + Modify Table Style… 表スタイルの変更… + Apply formatting to: 書式の適用先: + Whole Table 表全体 + Fill color: 塗りつぶしの色: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 921391be9..888924580 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,3 +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. + New Table Style Novo estilo de tabela + Modify Table Style Modificar estilo de tabela + New Table Style… Novo estilo de tabela… + Modify Table Style… Modificar estilo de tabela… + Apply formatting to: Aplicar formatação a: + Whole Table Tabela inteira + Fill color: Cor de preenchimento: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aeff3d2da..17b84bd9e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -943,3 +943,10 @@ Dark Темна Use system setting Як у системі A file dialog is already open. Діалогове вікно файлу вже відкрито. + New Table Style Новий стиль таблиці + Modify Table Style Змінити стиль таблиці + New Table Style… Новий стиль таблиці… + Modify Table Style… Змінити стиль таблиці… + Apply formatting to: Застосувати форматування до: + Whole Table Уся таблиця + Fill color: Колір заливки: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b2f9d3cba..35b7f9065 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,3 +942,10 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + New Table Style 新建表格样式 + Modify Table Style 修改表格样式 + New Table Style… 新建表格样式… + Modify Table Style… 修改表格样式… + Apply formatting to: 将格式应用于: + Whole Table 整个表格 + Fill color: 填充颜色: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a46dcca06..58c2d70f2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,3 +932,10 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + New Table Style 新增表格樣式 + Modify Table Style 修改表格樣式 + New Table Style… 新增表格樣式… + Modify Table Style… 修改表格樣式… + Apply formatting to: 將格式套用至: + Whole Table 整個表格 + Fill color: 填滿色彩: diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 95d8da794..e6d844a35 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -329,7 +329,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - let key = format!("tstyle:{style}:{ppp}"); + // Keyed by the style sheet too, so a modified table style redraws. + let skey = app.previews.styles_key(&app.session.doc, app.session.rev()); + let key = format!("tstyle:{style}:{skey}:{ppp}"); let doc = &app.session.doc; let tex = app.previews.get_or(ui.ctx(), &key, || { let mut tb = Table::new(5, 4, 100.0); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 27c999369..2d4e48b49 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1252,6 +1252,22 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { }); }); stack(ui, |ui| { + let can_modify = look.is_some() + && app + .session + .sel + .focus + .path + .cell() + .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()) + .is_some(); + menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| { + mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); + ui.close(); + } + }); split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { mi(ui, app, "No Color", "table.shading", json!({"color": null})); let theme = app.session.doc.settings.theme_colors.clone(); diff --git a/docs/parity.md b/docs/parity.md index afb626964..2de2ef497 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ 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. -**378 of 428 catalog features have commands (88.3%).** +**380 of 430 catalog features have commands (88.4%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Mailings | 17 | 17 | | Review | 26 | 29 | | View | 25 | 29 | -| Table Design | 5 | 5 | +| Table Design | 7 | 7 | | Table Layout | 28 | 30 | | Equation | 21 | 22 | | Picture Format | 13 | 13 | From 2fefdebcb195bee4b18960160aa93191f8e2363e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:21:11 +0200 Subject: [PATCH 03/16] Table styles: every region, a live preview, delete, custom styles first (#146) Follow-up to the custom table styles work: the New/Modify Table Style dialog now covers every region a table style formats, shows what the style will look like, and table styles can be deleted. - engine: table.newStyle / table.modifyStyle take lastRow (total row), firstColumn, lastColumn and bandedColumns regions with the same props as the others. New table.deleteStyle removes a custom table style: built-in ones are refused, styles based on it are re-based on its own base with its formatting folded in (so they look the same), and tables using it fall back to Table Grid. Undoable like any command. - doc/layout: table styles gain fill and cell borders for the first and last column and total row, and fill, text and borders for column bands; layout applies them in the standard order (column bands, row bands, first/last column, header/total row). - docx: firstCol/lastCol/lastRow/band1Vert shading and cell borders are read and written. - ui: the dialog's "Apply formatting to" lists all seven regions and a small live preview table shows the pending changes; Styles menu gets Delete Table Style (disabled for built-ins); the gallery shows the document's own table styles first. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/styles.rs | 25 ++++ crates/docx/src/read/mod.rs | 22 +++- crates/docx/src/write/mod.rs | 15 ++- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/table_style.rs | 168 +++++++++++++++++++++--- crates/engine/src/tests.rs | 70 ++++++++++ crates/layout/src/table.rs | 117 +++++++++++++---- crates/ui-egui/src/dialogs.rs | 189 ++++++++++++++++++++++----- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/ribbon.rs | 31 +++-- docs/parity.md | 4 +- 16 files changed, 561 insertions(+), 93 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 0844c9315..544f2b181 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -65,6 +65,21 @@ pub struct TableStyleParts { pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, + /// Shading and cell borders of the first column (`firstCol` region). + pub first_col_fill: Option, + pub first_col_borders: Option, + /// The last column (`lastCol` region). + pub last_col_fill: Option, + pub last_col_chr: CharProps, + pub last_col_borders: Option, + /// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left + /// unset falls back to `total_border_top`. + pub total_fill: Option, + pub total_borders: Option, + /// The odd column bands (`band1Vert` region). + pub col_band_fill: Option, + pub col_band_chr: CharProps, + pub col_band_borders: Option, } impl TableStyleParts { @@ -91,6 +106,16 @@ impl TableStyleParts { self.first_col_chr.overlay(&patch.first_col_chr); self.total_chr.overlay(&patch.total_chr); self.total_border_top = patch.total_border_top.or(self.total_border_top); + self.first_col_fill = patch.first_col_fill.or(self.first_col_fill); + self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders); + self.last_col_fill = patch.last_col_fill.or(self.last_col_fill); + self.last_col_chr.overlay(&patch.last_col_chr); + self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders); + self.total_fill = patch.total_fill.or(self.total_fill); + self.total_borders = merge(self.total_borders, patch.total_borders); + self.col_band_fill = patch.col_band_fill.or(self.col_band_fill); + self.col_band_chr.overlay(&patch.col_band_chr); + self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders); } } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index bf8e29bab..6b0d570fc 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -473,10 +473,28 @@ impl Reader<'_> { t.band_chr = chr; t.band_borders = cell_borders; } - Some("firstCol") => t.first_col_chr = chr, + Some("firstCol") => { + t.first_col_chr = chr; + t.first_col_fill = fill; + t.first_col_borders = cell_borders; + } + Some("lastCol") => { + t.last_col_chr = chr; + t.last_col_fill = fill; + t.last_col_borders = cell_borders; + } Some("lastRow") => { t.total_chr = chr; - t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border); + t.total_fill = fill; + // The top rule is kept on its own (as built-in styles have it); the other edges + // are the region's borders. + t.total_border_top = cell_borders.and_then(|b| b.top); + t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default()); + } + Some("band1Vert") => { + t.col_band_fill = fill; + t.col_band_chr = chr; + t.col_band_borders = cell_borders; } _ => {} } diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index e6a49c66e..f61fa4ad7 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -665,9 +665,18 @@ fn style_xml(w: &mut W, st: &Style) { }; cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None); cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders); - let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }); - cond(w, "lastRow", &t.total_chr, None, total_top); - cond(w, "firstCol", &t.first_col_chr, None, None); + // The total row's borders, its top edge falling back to the built-in top rule. + let total_borders = match (t.total_borders, t.total_border_top) { + (Some(mut b), top) => { + b.top = b.top.or(top); + Some(b) + } + (None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }), + }; + cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders); + cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders); + cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders); + cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders); cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders); } w.close("w:style"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index f5c881297..d08fa4359 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -329,6 +329,7 @@ Table Design|Table Style Options|Table Style Options|table.look Table Design|Table Styles|Table Styles|table.style Table Design|Table Styles|New Table Style|table.newStyle Table Design|Table Styles|Modify Table Style|table.modifyStyle +Table Design|Table Styles|Delete Table Style|table.deleteStyle Table Design|Table Styles|Shading|table.shading Table Design|Borders|Borders|table.borders Table Design|Borders|Border Painter|table.borderPainter diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs index 79baae174..11beed7ca 100644 --- a/crates/engine/src/cmd/table_style.rs +++ b/crates/engine/src/cmd/table_style.rs @@ -1,20 +1,28 @@ -//! Table Design › Table Styles: create and modify table styles (whole table, header row and -//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6). +//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and +//! total rows, first and last columns, banded rows and columns), stored as table styles in the +//! document's style sheet (ECMA-376 §17.7.6). + +use std::sync::Arc; use serde_json::{Value, json}; use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor}; -use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts}; +use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps}; +use wordcraft_doc::{Block, Table}; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; -/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, -/// `bandedRows`) takes the same shortcuts. +/// The regions a table style formats, by param name: the table style options they follow. +pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"]; + +/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow` +/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same +/// shortcuts. macro_rules! style_params { ($head:literal) => { concat!( "{", $head, - r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#, + r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#, r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"# ) }; @@ -25,6 +33,8 @@ pub fn specs() -> Vec { CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)), CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style) .params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)), + CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style) + .params(r#"{"style"?: string (default: the current table's)}"#), ] } @@ -43,7 +53,8 @@ fn current_table_style(s: &Session) -> Option { s.doc.table(s.sel.focus.story, &tp)?.props.style.clone() } -fn new_style(s: &mut Session, v: &Value) -> CmdResult { +/// The style `table.newStyle` would add for `v`, validated. +fn build_new(s: &Session, v: &Value) -> Result { let name = p::req_str(v, "name")?.trim().to_string(); if name.is_empty() { return Err(CmdError::Params("name is empty".into())); @@ -56,8 +67,14 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult { None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")), }; let id = s.doc.styles.new_id(&name); - let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() }; + let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() }; edit(&mut st, v)?; + Ok(st) +} + +fn new_style(s: &mut Session, v: &Value) -> CmdResult { + let st = build_new(s, v)?; + let id = st.id.clone(); s.doc.styles.upsert(st); s.dirty = true; if p::bool(v, "apply").unwrap_or(true) { @@ -66,11 +83,28 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"id": id})) } +/// The table style `v` names (`style`), else the current table's. +fn target(s: &Session, v: &Value) -> Result { + match p::str(v, "style") { + Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`"))), + None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into())), + } +} + fn modify_style(s: &mut Session, v: &Value) -> CmdResult { - let id = match p::str(v, "style") { - Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?, - None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?, - }; + let st = build_modified(s, v)?; + let id = st.id.clone(); + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(false) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +/// The style `table.modifyStyle` would store for `v`, validated. +fn build_modified(s: &Session, v: &Value) -> Result { + let id = target(s, v)?; let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); if let Some(n) = &rename && s.doc.styles.find(n).is_some_and(|x| x.id != id) @@ -96,12 +130,100 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult { st.based_on = Some(b); } edit(&mut st, v)?; - s.doc.styles.upsert(st); - s.dirty = true; - if p::bool(v, "apply").unwrap_or(false) { - apply(s, &id)?; + Ok(st) +} + +/// How table style `v["style"]` (or, without it, a new style) would look with the formatting +/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names +/// aren't checked; `None` when the params don't validate. +pub fn preview(s: &Session, v: &Value) -> Option { + if !v.is_object() { + return None; } - Ok(json!({"id": id})) + let mut v = v.clone(); + let st = if p::str(&v, "style").is_some() { + if let Some(o) = v.as_object_mut() { + o.remove("name"); + } + build_modified(s, &v).ok()? + } else { + // A name no style has: the look doesn't depend on it. + v["name"] = json!("\u{1}preview"); + build_new(s, &v).ok()? + }; + let id = st.id.clone(); + let mut sheet = s.doc.styles.clone(); + sheet.upsert(st); + sheet.table_style(&id) +} + +/// Delete a custom table style. Styles based on it are re-based on its own base with its +/// formatting folded in, so they look the same; tables using it take Table Grid (the style new +/// tables get), or no style when the document has no Table Grid. +fn delete_style(s: &mut Session, v: &Value) -> CmdResult { + let id = target(s, v)?; + let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?; + if st.builtin { + return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name))); + } + let mut rebased = Vec::new(); + for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) { + let mut para = st.para.clone(); + para.overlay(&child.para); + child.para = para; + let mut chr = st.chr.clone(); + chr.overlay(&child.chr); + child.chr = chr; + if child.kind == StyleKind::Table { + let mut parts = st.table.clone().unwrap_or_default(); + if let Some(t) = &child.table { + parts.overlay(t); + } + child.table = (parts != TableStyleParts::default()).then_some(parts); + } + child.based_on = st.based_on.clone(); + rebased.push(child.id.clone()); + } + s.doc.styles.styles.retain(|x| x.id != id); + let fallback = table_style_id(s, "TableGrid"); + let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0); + for part in s.doc.parts.values_mut() { + tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0); + } + s.dirty = true; + Ok(json!({"deleted": id, "tables": tables, "rebased": rebased})) +} + +/// Tables nest at most this deep for restyling. +const MAX_DEPTH: usize = 32; + +/// Whether table `t`, or one nested in it, uses style `id`. +fn uses_style(t: &Table, id: &str, depth: usize) -> bool { + t.props.style.as_deref() == Some(id) + || (depth < MAX_DEPTH + && t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1)))) +} + +/// Give every table in `blocks` that uses style `from` style `to`; the number restyled. +fn restyle(blocks: &mut [Arc], from: &str, to: Option<&str>, depth: usize) -> usize { + let mut n = 0; + for b in blocks { + if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) { + continue; + } + if let Block::Table(t) = Arc::make_mut(b) { + if t.props.style.as_deref() == Some(from) { + t.props.style = to.map(str::to_string); + n += 1; + } + if depth < MAX_DEPTH { + for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) { + n += restyle(&mut c.blocks, from, to, depth + 1); + } + } + } + } + n } /// Apply style `id` to the table at the caret, if any. @@ -123,6 +245,18 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { if let Some(r) = v.get("bandedRows") { region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?; } + if let Some(r) = v.get("lastRow") { + region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?; + } + if let Some(r) = v.get("firstColumn") { + region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?; + } + if let Some(r) = v.get("lastColumn") { + region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?; + } + if let Some(r) = v.get("bandedColumns") { + region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?; + } if let Some(n) = v.get("bandSize") { parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed1122940..daf1308f3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1763,3 +1763,73 @@ fn custom_table_style_applies_modifies_and_saves() { let t = back.body.iter().find_map(|b| b.as_table()).unwrap(); assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle"); } + +/// #146: the first/last column, total row and banded column regions apply in layout (later +/// regions win: the total row's fill beats the first column's) and round-trip through .docx. +#[test] +fn custom_table_style_column_and_total_regions() { + use wordcraft_doc::Rgb; + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 4})); + run( + &mut s, + "table.look", + json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}), + ); + let r = run( + &mut s, + "table.newStyle", + json!({"name": "Ledger", + "firstColumn": {"fill": "112233", "bold": true}, + "lastColumn": {"fill": "445566"}, + "lastRow": {"fill": "778899", "borders": true}, + "bandedColumns": {"fill": "ABCDEF"}}), + ); + let id = r["id"].as_str().unwrap().to_string(); + let fills = |s: &mut Session, c: Rgb| { + s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count() + }; + // 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the + // two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each. + assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row"); + assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column"); + assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column"); + assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band"); + + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let p = back.styles.get(&id).unwrap().table.clone().unwrap(); + assert_eq!( + (p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill), + (Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF))) + ); + assert!(p.total_borders.is_some_and(|b| b.any_visible())); +} + +/// #146: deleting a custom table style re-bases the styles based on it (keeping their look), +/// puts its tables on Table Grid, refuses built-in styles, and undoes. +#[test] +fn delete_table_style_falls_back_and_undoes() { + use wordcraft_doc::Rgb; + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}})); + run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}})); + let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone(); + assert_eq!(style(&s).as_deref(), Some("Base")); + assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in"); + assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in"); + + let r = run(&mut s, "table.deleteStyle", json!({})); + assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1))); + assert!(s.doc.styles.get("Base").is_none()); + assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid"); + let child = s.doc.styles.table_style("Child").unwrap(); + assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look"); + assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid")); + + run(&mut s, "edit.undo", json!({})); + assert!(s.doc.styles.get("Base").is_some()); + assert_eq!(style(&s).as_deref(), Some("Base")); + assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base")); +} diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 6b175796f..6a2136993 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -4,7 +4,7 @@ use wordcraft_doc::numbering::Counters; use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge}; -use wordcraft_doc::styles::TableStyleProps; +use wordcraft_doc::styles::{TableStyleParts, TableStyleProps}; use wordcraft_doc::{StoryRef, Table}; use wordcraft_geom::Rect; @@ -40,9 +40,9 @@ struct Turned { across: f32, /// List counters and equation count before the cell, to lay it out again the same way. snap: (Counters, u32), - /// Its formatting region (header row, total row, first column, row band) and cell index in - /// the row. - region: (bool, bool, bool, bool), + /// Its formatting region (header row, total row, first column, last column, row band, column + /// band) and cell index in the row. + region: (bool, bool, bool, bool, bool, bool), ci: usize, /// Top margin plus top border band. top: f32, @@ -95,12 +95,54 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1] } +/// A cell's borders from the table style's conditional regions it is in (`regions`: header row, +/// total row, first column, last column, row band, column band), later regions winning. Row +/// regions give their left/right edges at the table's sides and `inside_v` between cells; column +/// regions their top/bottom edges at the table's top and bottom and `between` between rows. +fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders { + let [header, total, first_col, last_col, band, col_band] = regions; + let row = |b: Option| { + b.map(|b| Borders { + top: b.top, + bottom: b.bottom, + left: if first_g { b.left } else { b.inside_v }, + right: if last_g { b.right } else { b.inside_v }, + between: None, + inside_v: None, + }) + }; + let col = |b: Option| { + b.map(|b| Borders { + top: if ri == 0 { b.top } else { b.between }, + bottom: if ri + 1 >= nrows { b.bottom } else { b.between }, + left: b.left, + right: b.right, + between: None, + inside_v: None, + }) + }; + let mut out = Borders::default(); + for (on, b) in [ + (col_band, col(p.col_band_borders)), + (band, row(p.band_borders)), + (first_col, col(p.first_col_borders)), + (last_col, col(p.last_col_borders)), + (header, row(p.header_borders)), + (total, row(p.total_borders)), + ] { + if on && let Some(b) = b { + out.overlay(&b); + } + } + out +} + pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout { let style = table_style(ctx, t); let parts = style.as_ref().map(|s| &s.parts); - // Cell text formatting per (header row, total row, first column, row band) region, built once - // per table. - let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new(); + // Cell text formatting per region (header row, total row, first column, last column, row + // band, column band), built once per table. + let mut cell_text: Vec<((bool, bool, bool, bool, bool, bool), CellText)> = Vec::new(); let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { @@ -182,15 +224,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let is_header = header_rows && ri == 0; let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2); - // The header row's or the odd band's own cell borders (conditional formatting). - let region_borders = if is_header { - parts.and_then(|p| p.header_borders) - } else if band { - parts.and_then(|p| p.band_borders) - } else { - None - } - .unwrap_or_default(); for (ci, cell) in row.cells.iter().enumerate() { let span = cell.span(); let x0 = colx.get(g).copied().unwrap_or(acc); @@ -198,15 +231,34 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let margins = cell.props.margins.unwrap_or(margins_def); let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; - let region = (is_header, is_total, t.props.look.first_column && g == 0, band); + let first_col = t.props.look.first_column && g == 0; + let last_col = t.props.look.last_column && g + span >= ncols && ncols > 1; + let first_cols = usize::from(t.props.look.first_column); + let col_band = t.props.look.banded_columns && !first_col && g.saturating_sub(first_cols).is_multiple_of(2); + let region = (is_header, is_total, first_col, last_col, band, col_band); + // Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table, + // column bands, row bands, first/last column, header/total row. if let Some(p) = parts { - if is_header { - fill = fill.or(p.header_fill); - } - if band && fill.is_none() { - fill = p.band_fill; - } - fill = fill.or(p.fill); + let row_fill = if is_header { + p.header_fill + } else if is_total { + p.total_fill + } else { + None + }; + let col_fill = if first_col { + p.first_col_fill + } else if last_col { + p.last_col_fill + } else { + None + }; + fill = fill + .or(row_fill) + .or(col_fill) + .or(if band { p.band_fill } else { None }) + .or(if col_band { p.col_band_fill } else { None }) + .or(p.fill); } if let Some(st) = &style && !cell_text.iter().any(|(r, _)| *r == region) @@ -214,12 +266,18 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // The whole table's formatting, then column, then row conditional formatting // (later regions win, ECMA-376 §17.7.6). let mut chr = st.chr.clone(); + if col_band { + chr.overlay(&st.parts.col_band_chr); + } if band { chr.overlay(&st.parts.band_chr); } - if region.2 { + if first_col { chr.overlay(&st.parts.first_col_chr); } + if last_col { + chr.overlay(&st.parts.last_col_chr); + } if is_header { chr.overlay(&st.parts.header_chr); } else if is_total { @@ -236,8 +294,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let edge = |own: Option, outer: bool, outer_b: Option, inner_b: Option| own.or(if outer { outer_b } else { inner_b }); let cb = cell.props.borders.unwrap_or_default(); - let rb = region_borders; let (first_g, last_g) = (g == 0, g + span >= ncols); + let rb = parts + .map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band])) + .unwrap_or_default(); let mut borders = Borders { top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between), bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between), @@ -246,7 +306,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava between: None, inside_v: None, }; - if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) { + if is_total + && parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none())) + && let Some(b) = parts.and_then(|p| p.total_border_top) + { borders.top = Some(b); } // Word keeps the cell's text clear of its top border (and the last row's of its bottom diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5e60aa6fb..aa9e46afe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -108,13 +108,13 @@ pub enum Dialog { name: String, /// Style id. based_on: String, - /// Index into `regions`: whole table, header row, banded rows. + /// Index into `regions` (see [`REGION_LABELS`]). region: usize, - regions: Box<[TableRegion; 3]>, + regions: Box<[TableRegion; 7]>, /// What the regions showed when the dialog opened (or the base style changed): only /// changes are sent, so everything else stays inherited. #[serde(skip)] - basis: Box<[TableRegion; 3]>, + basis: Box<[TableRegion; 7]>, }, Commands { query: String, @@ -236,8 +236,11 @@ pub struct TableRegion { pub color: String, } +/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names). +const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"]; + /// The regions of table style `id` as resolved through its based-on chain. -fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> { +fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> { let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() }; let p = &st.parts; let hex = |c: Option| c.map(|c| c.hex()).unwrap_or_default(); @@ -246,17 +249,136 @@ fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> { _ => String::new(), }; let lines = |b: Option| b.is_some_and(|b| b.any_visible()); - let mut header_chr = st.chr.clone(); - header_chr.overlay(&p.header_chr); - let mut band_chr = st.chr.clone(); - band_chr.overlay(&p.band_chr); + // A region's text: the whole table's formatting with the region's over it. + let region = |borders: Option, fill: Option, chr: &wordcraft_doc::CharProps| { + let mut c = st.chr.clone(); + c.overlay(chr); + TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) } + }; + let none = wordcraft_doc::CharProps::default(); Box::new([ - TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) }, - TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) }, - TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) }, + region(p.borders, p.fill, &none), + region(p.header_borders, p.header_fill, &p.header_chr), + region(p.total_borders, p.total_fill, &p.total_chr), + region(p.first_col_borders, p.first_col_fill, &p.first_col_chr), + region(p.last_col_borders, p.last_col_fill, &p.last_col_chr), + region(p.band_borders, p.band_fill, &p.band_chr), + region(p.col_band_borders, p.col_band_fill, &p.col_band_chr), ]) } +/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state: +/// only what changed from `basis`, so the rest stays inherited. +fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value { + let mut v = json!({"name": name.trim(), "basedOn": based_on}); + for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) { + let ch = region_changes(r, b); + if ch.as_object().is_some_and(|o| !o.is_empty()) { + v[key] = ch; + } + } + if let Some(id) = id { + v["style"] = json!(id); + } + v +} + +/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total +/// row), showing the regions `look` turns on: the Table Style dialog's live preview. +fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) { + use wordcraft_doc::props::{Border, Borders}; + const COLS: usize = 5; + const ROWS: usize = 5; + const TEXT: [[&str; COLS]; ROWS] = [ + ["", "Mon", "Tue", "Wed", "Sum"], + ["North", "4", "7", "2", "13"], + ["South", "6", "1", "5", "12"], + ["West", "3", "8", "4", "15"], + ["Total", "13", "16", "11", "40"], + ]; + let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover()); + let painter = ui.painter_at(rect); + // The page is white whatever the interface theme. + painter.rect_filled(rect, 2.0, egui::Color32::WHITE); + let t = Tokens::get(ui.ctx()); + painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let Some(st) = style else { return }; + let p = &st.parts; + let table = rect.shrink2(vec2(14.0, 10.0)); + let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32); + let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2); + let tb = p.borders.unwrap_or_default(); + for (ri, row) in TEXT.iter().enumerate() { + let header = look.header_row && ri == 0; + let total = look.total_row && ri + 1 == ROWS; + let band = look.banded_rows + && !header + && (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2); + for (ci, txt) in row.iter().enumerate() { + let first = look.first_column && ci == 0; + let last = look.last_column && ci + 1 == COLS; + let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2); + let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh)); + // Regions from lowest to highest priority, as layout applies them. + let regions = [ + (col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders), + (band, p.band_fill, &p.band_chr, p.band_borders), + (first, p.first_col_fill, &p.first_col_chr, p.first_col_borders), + (last, p.last_col_fill, &p.last_col_chr, p.last_col_borders), + (header, p.header_fill, &p.header_chr, p.header_borders), + (total, p.total_fill, &p.total_chr, p.total_borders), + ]; + let mut fill = p.fill; + let mut chr = st.chr.clone(); + let mut b = Borders { + top: if ri == 0 { tb.top } else { tb.between }, + bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between }, + left: if ci == 0 { tb.left } else { tb.inside_v }, + right: if ci + 1 == COLS { tb.right } else { tb.inside_v }, + between: None, + inside_v: None, + }; + for (on, f, c, rb) in regions { + if !on { + continue; + } + fill = f.or(fill); + chr.overlay(c); + if let Some(rb) = rb { + b.overlay(&Borders { between: None, inside_v: None, ..rb }); + } + } + if total && p.total_borders.is_none_or(|x| x.top.is_none()) { + b.top = p.total_border_top.or(b.top); + } + if let Some(f) = fill { + painter.rect_filled(cell, 0.0, rgb(f)); + } + let edge = |from: egui::Pos2, to: egui::Pos2, e: Option| { + if let Some(e) = e.filter(Border::is_visible) { + let color = e.color.map_or(egui::Color32::BLACK, rgb); + painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color)); + } + }; + edge(cell.left_top(), cell.right_top(), b.top); + edge(cell.left_bottom(), cell.right_bottom(), b.bottom); + edge(cell.left_top(), cell.left_bottom(), b.left); + edge(cell.right_top(), cell.right_bottom(), b.right); + let color = match chr.color { + Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c), + _ => egui::Color32::BLACK, + }; + let font = egui::FontId::proportional(11.0); + let pos = cell.left_center() + vec2(4.0, 0.0); + painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color); + if chr.bold.unwrap_or(false) { + // A second pass a hair to the right reads as bold at this size. + painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color); + } + } + } +} + /// The style of the table at the caret, when it is a table style. fn current_table_style(app: &WordApp) -> Option { let s = &app.session; @@ -1027,12 +1149,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.end_row(); ui.label(tl!("Apply formatting to:")); - let names = ["Whole Table", "Header Row", "Banded Rows"]; egui::ComboBox::from_id_salt("tstyle_region") .width(200.0) - .selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table"))) + .selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table"))) .show_ui(ui, |ui| { - for (i, n) in names.iter().enumerate() { + for (i, n) in REGION_LABELS.iter().enumerate() { ui.selectable_value(region, i, tl!(n)); } }); @@ -1059,24 +1180,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.end_row(); }); } + // Live preview: the style as OK would leave it, on the current table's options with + // the region being edited turned on. + let v = table_style_params(id.as_deref(), name, based_on, regions, basis); + let mut look = app + .session + .sel + .focus + .path + .cell() + .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)) + .unwrap_or_default(); + match *region { + 1 => look.header_row = true, + 2 => look.total_row = true, + 3 => look.first_column = true, + 4 => look.last_column = true, + 5 => look.banded_rows = true, + 6 => look.banded_columns = true, + _ => {} + } + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Preview")).small().weak()); + table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look); let (ok, cancel) = buttons(ui, tl!("OK")); if ok { - let mut v = json!({"name": name.trim(), "basedOn": based_on}); - for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() { - if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) { - let ch = region_changes(r, b); - if ch.as_object().is_some_and(|o| !o.is_empty()) { - v[key] = ch; - } - } - } - let cmd = match id { - Some(sid) => { - v["style"] = json!(sid); - "table.modifyStyle" - } - None => "table.newStyle", - }; + let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" }; if let Err(e) = app.run(cmd, v) { app.status(e); return false; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19de3af08..6e5cab17d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -955,3 +955,5 @@ Apply formatting to: Aplicar formato a: Whole Table Toda la tabla Fill color: Color de relleno: + Delete Table Style Eliminar estilo de tabla + Preview Vista previa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4acca7de1..544e91151 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -938,3 +938,5 @@ Apply formatting to: 書式の適用先: Whole Table 表全体 Fill color: 塗りつぶしの色: + Delete Table Style 表スタイルの削除 + Preview プレビュー diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 888924580..17b112674 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -950,3 +950,5 @@ Apply formatting to: Aplicar formatação a: Whole Table Tabela inteira Fill color: Cor de preenchimento: + Delete Table Style Excluir estilo de tabela + Preview Visualização diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17b84bd9e..524b70d75 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -950,3 +950,5 @@ Apply formatting to: Застосувати форматування до: Whole Table Уся таблиця Fill color: Колір заливки: + Delete Table Style Видалити стиль таблиці + Preview Попередній перегляд diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 35b7f9065..fe6106be4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -949,3 +949,5 @@ Apply formatting to: 将格式应用于: Whole Table 整个表格 Fill color: 填充颜色: + Delete Table Style 删除表格样式 + Preview 预览 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 58c2d70f2..1ad5fe2ed 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -939,3 +939,5 @@ Apply formatting to: 將格式套用至: Whole Table 整個表格 Fill color: 填滿色彩: + Delete Table Style 刪除表格樣式 + Preview 預覽 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2d4e48b49..1ca6869bc 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1233,18 +1233,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Table Styles", None, app, |ui, app| { - let styles: Vec<(String, String)> = app + // The document's own styles first, so a new one shows without scrolling. + let mut styles: Vec<(bool, String, String)> = app .session .doc .styles .styles .iter() .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden) - .map(|s| (s.id.clone(), s.name.clone())) + .map(|s| (s.builtin, s.id.clone(), s.name.clone())) .collect(); + styles.sort_by_key(|(builtin, _, _)| *builtin); egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| { ui.horizontal(|ui| { - for (id, name) in styles { + for (_, id, name) in styles { if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() { let _ = app.run("table.style", json!({"style": id})); } @@ -1252,21 +1254,26 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { }); }); stack(ui, |ui| { - let can_modify = look.is_some() - && app - .session - .sel - .focus - .path - .cell() - .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()) - .is_some(); + let current = look.and_then(|_| { + let (tp, _, _) = app.session.sel.focus.path.cell()?; + let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?; + app.session.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.builtin) + }); + let can_modify = current.is_some(); + // Built-in table styles can't be deleted. + let can_delete = current == Some(false); menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| { mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); ui.close(); } + if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() { + if let Err(e) = app.run("table.deleteStyle", json!({})) { + app.status(e); + } + ui.close(); + } }); split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { mi(ui, app, "No Color", "table.shading", json!({"color": null})); diff --git a/docs/parity.md b/docs/parity.md index 2de2ef497..ddefbc44d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ 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 431 catalog features have commands (88.4%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Mailings | 17 | 17 | | Review | 26 | 29 | | View | 25 | 29 | -| Table Design | 7 | 7 | +| Table Design | 8 | 8 | | Table Layout | 28 | 30 | | Equation | 21 | 22 | | Picture Format | 13 | 13 | From c97b568d628abca22e04d1c0a246092a833c20ae Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:43:00 +0200 Subject: [PATCH 04/16] Touchpad scrolling follows the fingers and coasts after a flick; mouse wheel scrolls further (#122) (#252) The document canvas used egui's built-in wheel handling: touchpad deltas lost their first and last events, Wayland compositors leave kinetic scrolling to the app so a flick stopped dead, and a wheel notch scrolled only 40 px. The canvas now scrolls itself (crates/ui-egui/src/scroll.rs): touchpad pixel deltas move the page 1:1 in the same frame; on Linux/BSD a flick keeps coasting with time-based exponential decay, stopped by a click, a key, a wheel notch or the end of the document (macOS, Windows and browsers already send their own momentum); a wheel notch scrolls about three lines (56 px at 100%, gently scaled with zoom) and still eases in. Ctrl+wheel zoom and Shift+wheel sideways scrolling are unchanged. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 33 +++- crates/ui-egui/src/lib.rs | 41 +++++ crates/ui-egui/src/scroll.rs | 332 +++++++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/scroll.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b4eb32712..1114e5a10 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -44,6 +44,11 @@ pub struct CanvasState { pub mini_anchor: Option, /// A picture, shape or text box being dragged by its frame. pub(crate) obj_drag: Option, + /// Wheel/touchpad scrolling (smooth notches, touchpad momentum). + pub(crate) wheel: crate::scroll::CanvasScroll, + /// The scroll offset and its maximum at the end of last frame. + pub(crate) scroll_offset: egui::Vec2, + scroll_max: egui::Vec2, /// Pages per row last frame; when it changes the caret's page is scrolled back into view. pub cols: usize, } @@ -78,6 +83,9 @@ impl Default for CanvasState { obj_drag: None, context_menu_open: false, mini_anchor: None, + wheel: Default::default(), + scroll_offset: egui::Vec2::ZERO, + scroll_max: egui::Vec2::ZERO, cols: 1, } } @@ -288,7 +296,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.scroll_to_caret = false; let mut origin = area.min; let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); - let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).show_viewport(&mut ui_area, |ui, viewport| { + // The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with + // momentum, wheel notches eased in. Applied before drawing, so it shows this frame. + let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none(); + let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT); + let opts = ui.ctx().options(|o| o.input_options); + let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height())); + let mut scroll_area = egui::ScrollArea::both() + .id_salt("canvas_scroll") + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() }); + if delta != egui::Vec2::ZERO { + let before = app.canvas.scroll_offset; + let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max); + if after == before { + app.canvas.wheel.hit_edge(); + } + scroll_area = scroll_area.scroll_offset(after); + } + if app.canvas.wheel.is_animating() { + ui.ctx().request_repaint(); + } + let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| { origin = ui.min_rect().min - viewport.min.to_vec2(); let content = Rect::from_min_size(ui.min_rect().min, geo.size); let resp = ui.allocate_rect(content, Sense::click_and_drag()); @@ -491,6 +520,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); + app.canvas.scroll_offset = out.state.offset; + app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); let (resp, rects) = out.inner; // Focus: the canvas takes keyboard focus on click and keeps Tab/arrows. if resp.clicked() || resp.drag_started() || app.canvas.want_focus { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3899c71e2..6cdf5fdb6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod panes; pub mod previews; pub mod read_aloud; pub mod ribbon; +pub mod scroll; pub mod theme; pub mod widgets; pub mod window_geometry; @@ -1076,6 +1077,46 @@ mod tests { assert!(a.canvas.scale < zoomed, "Ctrl+wheel down zooms out"); } + /// Issue #122: touchpad deltas scroll the page 1:1 at once; a wheel notch eases in to about + /// three lines. + #[test] + fn touchpad_scrolls_one_to_one_and_wheel_notches_ease_in() { + let ctx = egui::Context::default(); + let mut a = app(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a, Vec::new()); + } + let over_page = a.canvas.canvas_rect.unwrap().center(); + frame(&mut a, vec![egui::Event::PointerMoved(over_page)]); + let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase, modifiers: egui::Modifiers::NONE }; + let start = a.canvas.scroll_offset.y; + frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, -40.0, egui::TouchPhase::Start)]); + assert_eq!(a.canvas.scroll_offset.y, start + 40.0, "the first touchpad delta lands in the same frame"); + // Fingers lift; the coast after it is left to the unit tests (crate::scroll). + frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, 0.0, egui::TouchPhase::End)]); + a.canvas.wheel = Default::default(); + let at = a.canvas.scroll_offset.y; + frame(&mut a, vec![wheel(egui::MouseWheelUnit::Line, -1.0, egui::TouchPhase::Move)]); + let first = a.canvas.scroll_offset.y - at; + for _ in 0..30 { + frame(&mut a, Vec::new()); + } + let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT); + assert!(first > 0.0 && first < notch, "a notch eases in: {first}"); + assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at); + } + /// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer. #[test] fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() { diff --git a/crates/ui-egui/src/scroll.rs b/crates/ui-egui/src/scroll.rs new file mode 100644 index 000000000..6ba32b76a --- /dev/null +++ b/crates/ui-egui/src/scroll.rs @@ -0,0 +1,332 @@ +//! How the document canvas scrolls (issue #122). +//! +//! egui's built-in wheel handling smooths every delta it thinks is coarse and drops the deltas of +//! a touchpad's first and last events, and Wayland compositors leave kinetic scrolling to the app +//! (GTK, Qt and browsers synthesise it after the fingers lift). So the canvas scrolls itself: +//! - touchpad (pixel) deltas move the page 1:1, with no animation; +//! - after a touchpad flick the page coasts and slows down ([`Momentum`]), on platforms whose +//! system doesn't already send momentum events (not macOS, Windows or the web); +//! - a mouse-wheel notch scrolls about three text lines and eases in over ~0.1 s, like egui. + +use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2}; + +/// Screen points one mouse-wheel notch scrolls at 100% zoom: about three lines of body text, +/// like other desktop apps (egui's default is 40). +pub const NOTCH_PX: f32 = 56.0; + +/// Whether to synthesise kinetic scrolling. macOS and Windows send their own momentum events +/// (or report touchpads as line deltas), and browsers do their own; only Linux/BSD need it. +const SYNTH_MOMENTUM: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_os = "windows", target_arch = "wasm32")); + +/// Longest frame time trusted for velocity and decay (a stall shouldn't fling the page). +const MAX_DT: f32 = 0.1; +/// Shortest event spacing trusted for velocity (two events in one burst aren't infinitely fast). +const MIN_DT: f32 = 1.0 / 500.0; +/// Velocity is averaged over roughly this many seconds of input. +const VELOCITY_WINDOW: f32 = 0.04; +/// With no "fingers lifted" event, coast after this long without input. +const IDLE_RELEASE: f32 = 0.05; +/// Fingers that rested this long before lifting don't fling. +const STALE: f32 = 0.1; +/// Slower flicks than this (points/s) don't coast… +const MIN_FLING: f32 = 150.0; +/// …and coasting stops below this speed. +const STOP_SPEED: f32 = 20.0; +/// Fastest coast (points/s). +const MAX_SPEED: f32 = 8000.0; +/// Exponential decay rate of the coast (1/s): speed falls to 1/e every ~0.33 s. +const DECAY: f32 = 3.0; + +/// Kinetic scrolling for touchpads: tracks the scroll velocity while the fingers move and keeps +/// scrolling with exponentially decaying speed after they lift. Time-based, so frame-rate +/// independent. Deltas are in screen points, positive = content moves down/right. +#[derive(Clone, Debug, Default)] +pub struct Momentum { + velocity: Vec2, + /// Seconds since the last fed delta. + since_input: f32, + /// Fingers are on the touchpad (deltas arrived and no lift yet). + streaming: bool, + coasting: bool, + /// The device reports when the fingers lift, so don't guess it from a pause. + reports_lift: bool, +} + +impl Momentum { + /// A frame's touchpad delta, `dt` seconds after the previous frame. Non-finite input is ignored. + pub fn feed(&mut self, delta: Vec2, dt: f32) { + if !delta.is_finite() || !dt.is_finite() { + return; + } + let dt = dt.clamp(0.0, MAX_DT); + let fresh = !self.streaming || self.since_input + dt > STALE; + let span = if fresh { dt } else { self.since_input + dt }.clamp(MIN_DT, MAX_DT); + let sample = delta / span; + self.velocity = if fresh { + sample + } else { + let a = 1.0 - (-span / VELOCITY_WINDOW).exp(); + self.velocity + (sample - self.velocity) * a + }; + let speed = self.velocity.length(); + if speed > MAX_SPEED { + self.velocity *= MAX_SPEED / speed; + } + self.since_input = 0.0; + self.streaming = true; + self.coasting = false; + } + + /// The fingers lifted: coast if the flick was fast and recent enough. + pub fn release(&mut self) { + self.reports_lift = true; + self.start_coast(); + } + + fn start_coast(&mut self) { + let fling = self.streaming && self.since_input <= STALE && self.velocity.length() >= MIN_FLING; + self.streaming = false; + if fling { + self.coasting = true; + } else { + self.cancel(); + } + } + + /// Stop dead (a click, a key, a wheel notch, the end of the document). + pub fn cancel(&mut self) { + self.velocity = Vec2::ZERO; + self.coasting = false; + self.streaming = false; + } + + /// Advance a frame without touchpad input; returns how far to coast this frame. + pub fn tick(&mut self, dt: f32) -> Vec2 { + if !dt.is_finite() || dt <= 0.0 { + return Vec2::ZERO; + } + let dt = dt.min(MAX_DT); + self.since_input += dt; + if self.streaming && !self.reports_lift && self.since_input >= IDLE_RELEASE { + self.start_coast(); + } + if !self.coasting { + return Vec2::ZERO; + } + // Exact integral of v·e^(−kt) over the frame, so the distance doesn't depend on frame rate. + let keep = (-DECAY * dt).exp(); + let step = self.velocity * ((1.0 - keep) / DECAY); + self.velocity *= keep; + if self.velocity.length() < STOP_SPEED { + self.cancel(); + } + step + } + + /// Still moving or waiting to decide whether to coast: keep repainting. + pub fn is_active(&self) -> bool { + self.coasting || (self.streaming && !self.reports_lift) + } +} + +/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches. +#[derive(Clone, Debug, Default)] +pub struct CanvasScroll { + momentum: Momentum, + /// Wheel distance still to scroll, eased out over a few frames. + pending: Vec2, +} + +impl CanvasScroll { + /// This frame's scroll delta in screen points (positive = content moves down/right) from the + /// wheel/touchpad events in `input`. `hovered`: the pointer is over the canvas (only then do + /// its wheel events scroll it). `notch`: points per wheel notch. `page`: viewport height. + pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32) -> Vec2 { + let notch = if notch.is_finite() { notch.clamp(1.0, 1000.0) } else { NOTCH_PX }; + let page = if page.is_finite() { page.clamp(0.0, 100_000.0) } else { 0.0 }; + // Browsers report lines (Firefox: three per notch) where native platforms report notches. + let line = if cfg!(target_arch = "wasm32") { notch / 3.0 } else { notch }; + let dt = input.unstable_dt; + let mut now = Vec2::ZERO; + let mut touchpad = Vec2::ZERO; + let mut lifted = false; + let mut stop = false; + for event in &input.events { + match event { + Event::MouseWheel { unit, delta, phase, modifiers } => { + // Ctrl/⌘+wheel zooms (egui turns it into a zoom factor). + if !hovered || modifiers.matches_any(opts.zoom_modifier) { + continue; + } + let d = axis_lock(if delta.is_finite() { *delta } else { Vec2::ZERO }, *modifiers, opts); + match unit { + // Touchpads (and precise mice): 1:1. Browsers also report wheel notches in + // points, so big jumps there still ease in, like egui does. + MouseWheelUnit::Point if !cfg!(target_arch = "wasm32") || d.length() < 8.0 => touchpad += d, + MouseWheelUnit::Point => self.pending += d, + // Whole notches ease in; fractional ones (X11/Windows touchpads, free-spinning + // wheels) are already smooth. + MouseWheelUnit::Line if d.x.fract() == 0.0 && d.y.fract() == 0.0 => { + stop = true; + self.pending += d * line; + } + MouseWheelUnit::Line => now += d * line, + MouseWheelUnit::Page => self.pending += d * page, + } + lifted |= matches!(phase, TouchPhase::End | TouchPhase::Cancel); + } + Event::Key { pressed: true, .. } | Event::PointerButton { pressed: true, .. } | Event::Text(_) => stop = true, + _ => {} + } + } + now += touchpad; + let fed = touchpad != Vec2::ZERO; + if SYNTH_MOMENTUM { + if fed { + self.momentum.feed(touchpad, dt); + } + if lifted { + self.momentum.release(); + } + } + // A click, key or wheel notch stops a coast (even one that started this frame). + if stop { + self.momentum.cancel(); + } + if SYNTH_MOMENTUM && !fed { + now += self.momentum.tick(dt); + } + // Ease wheel notches in: 90% of the way in 0.1 s (egui's curve). + if self.pending != Vec2::ZERO { + let t = if dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 }; + let ease = |pending: f32| if pending.abs() < 1.0 { pending } else { pending * t }; + let step = Vec2::new(ease(self.pending.x), ease(self.pending.y)); + now += step; + self.pending -= step; + } + now + } + + /// The scroll ran into the end of the document: stop coasting and drop queued notches. + pub fn hit_edge(&mut self) { + self.momentum.cancel(); + self.pending = Vec2::ZERO; + } + + /// Scrolling continues without new input: keep repainting. + pub fn is_animating(&self) -> bool { + self.pending != Vec2::ZERO || self.momentum.is_active() + } +} + +/// Shift scrolls sideways and Alt scrolls vertically, like egui's own scroll areas. +fn axis_lock(d: Vec2, modifiers: Modifiers, opts: &InputOptions) -> Vec2 { + let horizontal = modifiers.matches_any(opts.horizontal_scroll_modifier); + let vertical = modifiers.matches_any(opts.vertical_scroll_modifier); + match (horizontal, vertical) { + (true, false) => Vec2::new(d.x + d.y, 0.0), + (false, true) => Vec2::new(0.0, d.x + d.y), + _ => d, + } +} + +/// Points per wheel notch at `zoom` (1.0 = 100%): more when zoomed in, less when zoomed out, but +/// gently, so zoomed-out overviews don't crawl and close-ups don't jump. +pub fn notch_px(zoom: f32) -> f32 { + let z = if zoom.is_finite() && zoom > 0.0 { zoom.sqrt().clamp(0.75, 2.0) } else { 1.0 }; + NOTCH_PX * z +} + +#[cfg(test)] +mod tests { + use super::*; + + const FRAME: f32 = 1.0 / 60.0; + + fn flick(m: &mut Momentum) { + for _ in 0..6 { + m.feed(Vec2::new(0.0, -30.0), FRAME); + } + } + + #[test] + fn flick_coasts_then_decays_to_a_stop() { + let mut m = Momentum::default(); + flick(&mut m); + m.release(); + let first = m.tick(FRAME); + assert!(first.y < -10.0, "keeps going the same way after the fingers lift: {first:?}"); + let mut total = first.y; + let mut last = first.y.abs(); + let mut frames = 0; + while m.is_active() { + let d = m.tick(FRAME).y; + assert!(d.abs() <= last + 1e-3, "slows down"); + last = d.abs(); + total += d; + frames += 1; + assert!(frames < 600, "stops within ten seconds"); + } + // 1800 pt/s with a 1/3 s time constant: a light coast of about 600 pt. + assert!((-700.0..-400.0).contains(&total), "coasted {total}"); + assert_eq!(m.tick(FRAME), Vec2::ZERO); + + // Frame-rate independent: 144 Hz coasts about as far. + let mut fast = Momentum::default(); + flick(&mut fast); + fast.release(); + let mut total144 = 0.0; + while fast.is_active() { + total144 += fast.tick(1.0 / 144.0).y; + } + assert!((total144 - total).abs() < 30.0, "{total144} vs {total}"); + } + + #[test] + fn new_input_cancels_and_slow_or_stale_lifts_dont_fling() { + let mut m = Momentum::default(); + flick(&mut m); + m.release(); + assert_ne!(m.tick(FRAME), Vec2::ZERO); + m.cancel(); + assert!(!m.is_active()); + assert_eq!(m.tick(FRAME), Vec2::ZERO); + + // Fingers touching again mid-coast take over: no coasting until they lift again. + flick(&mut m); + m.release(); + m.feed(Vec2::new(0.0, -1.0), FRAME); + assert_eq!(m.tick(FRAME), Vec2::ZERO, "the coast stopped"); + m.release(); + assert!(!m.is_active(), "a slow drag doesn't fling"); + + // Resting the fingers before lifting doesn't fling either. + flick(&mut m); + for _ in 0..10 { + m.tick(FRAME); + } + m.release(); + assert!(!m.is_active()); + } + + #[test] + fn hostile_numbers_are_ignored() { + let mut m = Momentum::default(); + m.feed(Vec2::new(f32::NAN, f32::INFINITY), FRAME); + m.feed(Vec2::new(0.0, -30.0), f32::NAN); + assert!(!m.is_active()); + flick(&mut m); + m.feed(Vec2::new(0.0, f32::NEG_INFINITY), FRAME); + m.release(); + assert_eq!(m.tick(f32::NAN), Vec2::ZERO); + assert_eq!(m.tick(f32::INFINITY), Vec2::ZERO); + let d = m.tick(FRAME); + assert!(d.is_finite() && d.y < 0.0); + // A huge delta in a tiny frame is capped. + let mut m = Momentum::default(); + m.feed(Vec2::new(0.0, 1e30), 1e-9); + m.release(); + assert!(m.tick(FRAME).length() <= MAX_SPEED * FRAME); + assert!(notch_px(f32::NAN).is_finite() && notch_px(-1.0) == NOTCH_PX); + } +} From 69eaf9925c5c53dc1f3caa5e733e211be5b587af Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 19:43:42 +0200 Subject: [PATCH 05/16] View tab: Zoom In/Out buttons that step from the shown zoom (#67) (#232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom In/Out stepped from the stored manual zoom, which goes stale while a fit mode (Page Width, One Page, Multiple Pages) sizes the page: from a 163% Page Width, Zoom In jumped to 110%, i.e. zoomed out. The canvas now keeps view.zoom equal to the shown zoom while a fit mode is on, so Zoom In/Out, the Zoom dialog and view.state all start from what the user sees. The View › Zoom group gains Zoom In / Zoom Out buttons (code-drawn icons; the catalog already lists them there), and the Zoom dialog's slider is logarithmic and wider so a short drag no longer jumps from 100% to 480%. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/view.rs | 4 ++-- crates/engine/src/tests.rs | 41 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 13 ++++++++++ crates/ui-egui/src/dialogs.rs | 5 +++- crates/ui-egui/src/icons.rs | 8 +++++++ crates/ui-egui/src/lib.rs | 45 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 5 ++++ 7 files changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7c6d99e60..d03b51aa6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -39,12 +39,12 @@ pub fn specs() -> Vec { .params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#) .pure(), CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(), - CommandSpec::new("view.zoomIn", "Zoom In", "Status Bar", |s, _| { + CommandSpec::new("view.zoomIn", "Zoom In", "View › Zoom", |s, _| { let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0); zoom(s, &json!({"value": z * 100.0})) }) .pure(), - CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| { + CommandSpec::new("view.zoomOut", "Zoom Out", "View › Zoom", |s, _| { let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1); zoom(s, &json!({"value": z * 100.0})) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d5830ad8f..973d52333 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1700,3 +1700,44 @@ fn timestamps_come_from_the_clock() { // Fixed-width ISO strings sort by time. assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } + +/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom +/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck +/// short of the 10%–500% limits. +#[test] +fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() { + let mut s = s(); + let pct = |s: &Session| (s.view.zoom * 100.0).round() as i32; + run(&mut s, "view.zoomIn", json!({})); + assert_eq!(pct(&s), 110); + run(&mut s, "view.zoomOut", json!({})); + run(&mut s, "view.zoomOut", json!({})); + assert_eq!(pct(&s), 90); + for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] { + run(&mut s, fit, json!({})); + assert!(!s.view.fit.is_empty()); + // What the canvas reports while a fit mode shows the page at 163%. + s.view.zoom = 1.63; + run(&mut s, "view.zoomIn", json!({})); + assert_eq!((pct(&s), s.view.fit.as_str(), s.view.multi_page), (170, "", false), "{fit}"); + run(&mut s, fit, json!({})); + s.view.zoom = 1.63; + run(&mut s, "view.zoomOut", json!({})); + assert_eq!((pct(&s), s.view.fit.as_str()), (150, ""), "{fit}"); + } + let mut last = pct(&s); + for _ in 0..60 { + run(&mut s, "view.zoomIn", json!({})); + assert!(pct(&s) > last || pct(&s) == 500); + last = pct(&s); + } + assert_eq!(last, 500); + for _ in 0..60 { + run(&mut s, "view.zoomOut", json!({})); + assert!(pct(&s) < last || pct(&s) == 10); + last = pct(&s); + } + assert_eq!(last, 10); + run(&mut s, "view.zoom100", json!({})); + assert_eq!(pct(&s), 100); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1114e5a10..1dcc4a965 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -202,6 +202,18 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { Geometry { rects, size, scale, cols } } +/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom +/// equal to what is shown, as Word does. Zoom In/Out, the Zoom dialog and `view.state` then start +/// from the visible zoom; before, they stepped from the stale manual zoom, so Zoom In from a 163% +/// Page Width jumped to 110% (issue #67). +pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) { + let v = &mut app.session.view; + if v.fit.is_empty() || v.read_mode || v.mode != wordcraft_layout::ViewMode::Print || !scale.is_finite() { + return; + } + v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0); +} + /// Fingerprint of a page's content for the texture cache. fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -270,6 +282,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0)); app.canvas.scale = geo.scale; + sync_fit_zoom(app, geo.scale); // Zooming or resizing reflowed the pages into a different number of columns: the caret's page // moved, so bring it back into view rather than leave the reader somewhere else. if geo.cols != app.canvas.cols { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9b080d9ed..259eceb1e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -790,7 +790,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(percent, p, format!("{p}%")); } }); - ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%")); + // Logarithmic like the status bar slider, and wide enough to aim: the default 100 pt + // linear slider jumped from 100% to 480% within a short drag (issue #67). + ui.spacing_mut().slider_width = 220.0; + ui.add(egui::Slider::new(percent, 10.0..=500.0).logarithmic(true).step_by(1.0).suffix("%")); let mut fit = None; ui.horizontal(|ui| { if ui.button(tl!("Page width")).clicked() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 24913756d..884e3b542 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -860,6 +860,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.line(&[(6.0, 8.5), (11.0, 8.5)]); pen.line(&[(8.5, 6.0), (8.5, 11.0)]); } + "zoomIn" | "zoomOut" => { + pen.circle(8.5, 8.5, 5.5, c); + pen.line_c(&[(12.5, 12.5), (17.5, 17.5)], a); + pen.line(&[(6.0, 8.5), (11.0, 8.5)]); + if name == "zoomIn" { + pen.line(&[(8.5, 6.0), (8.5, 11.0)]); + } + } "zoom100" => pen.text(10.0, 10.0, 7.5, "100", a, true), "onePage" => pen.page(5.0, 2.0, 15.0, 18.0), "multiplePages" => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6cdf5fdb6..4fef649e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1077,6 +1077,51 @@ mod tests { assert!(a.canvas.scale < zoomed, "Ctrl+wheel down zooms out"); } + /// Issue #67: View › Zoom In from a fit mode zoomed *out*: Page Width showed 163% but Zoom In + /// stepped from the stale manual 100% to 110%. + #[test] + fn view_tab_zoom_steps_from_the_shown_zoom_in_fit_modes() { + let ctx = egui::Context::default(); + let mut a = app(); + a.run("ui.tab", json!({"tab": "View"})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0)); + let frame = |a: &mut WordApp| { + for _ in 0..4 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + } + a.canvas.scale / canvas::PX_PER_PT + }; + let base = frame(&mut a); + assert!((base - 1.0).abs() < 1e-3); + for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] { + a.run(fit, json!({})).unwrap(); + let fitted = frame(&mut a); + assert!((fitted - base).abs() > 0.05, "{fit} changes the zoom"); + assert!((a.session.view.zoom - fitted).abs() < 1e-3, "{fit}: session zoom follows the shown zoom"); + a.run("view.zoomIn", json!({})).unwrap(); + let zin = frame(&mut a); + assert!(zin > fitted, "{fit}: Zoom In zooms in: {fitted} -> {zin}"); + a.run(fit, json!({})).unwrap(); + frame(&mut a); + a.run("view.zoomOut", json!({})).unwrap(); + let zout = frame(&mut a); + assert!(zout < fitted, "{fit}: Zoom Out zooms out: {fitted} -> {zout}"); + // The Zoom dialog opens at the shown zoom. + a.run(fit, json!({})).unwrap(); + frame(&mut a); + a.run("ui.dialog", json!({"name": "zoom"})).unwrap(); + assert!(matches!(a.dialog, Some(dialogs::Dialog::Zoom { percent }) if (percent - (fitted * 100.0).round()).abs() < 1.0)); + a.dialog = None; + } + a.run("view.zoom100", json!({})).unwrap(); + assert!((frame(&mut a) - 1.0).abs() < 1e-3); + } + /// Issue #122: touchpad deltas scroll the page 1:1 at once; a wheel notch eases in to about /// three lines. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 27c999369..396310d52 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1044,6 +1044,11 @@ fn view(app: &mut WordApp, ui: &mut Ui) { group(ui, "Zoom", None, app, |ui, app| { big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false); big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false); + // Step the zoom up and down by 10% (issue #67), from whatever the page shows now. + stack(ui, |ui| { + small(ui, app, "zoomIn", Some("Zoom In"), "Zoom In", "view.zoomIn", json!({}), false); + small(ui, app, "zoomOut", Some("Zoom Out"), "Zoom Out", "view.zoomOut", json!({}), false); + }); stack(ui, |ui| { small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage"); small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page); From 19e2e37ca63fbba4133efe07c2852242cb93b1c1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:44:09 +0200 Subject: [PATCH 06/16] =?UTF-8?q?View=20=E2=80=BA=20Gridlines=20shows=20th?= =?UTF-8?q?e=20drawing=20grid;=20Table=20Layout=20=E2=80=BA=20View=20Gridl?= =?UTF-8?q?ines=20outlines=20cells=20(#69)=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Show › Gridlines only outlined table cells, so with no table on the page it seemed to do nothing. It now draws a light 0.5 cm grid over the page's text area, inside the margins, in Print Layout. Table Layout › View Gridlines gets its own command, table.viewGridlines, with its own view flag, and draws the table cell outlines as before. The ribbon button, keytip and parity catalog point at it. Both are screen-only overlays drawn by the canvas; print, PDF and image exports never show them. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/catalog.rs | 2 +- crates/engine/src/cmd/view.rs | 9 +++- crates/engine/src/session.rs | 5 ++ crates/engine/src/tests.rs | 31 +++++++++++ crates/ui-egui/src/canvas.rs | 97 ++++++++++++++++++++++++++++++++++- crates/ui-egui/src/keytips.rs | 2 +- crates/ui-egui/src/ribbon.rs | 4 +- 7 files changed, 143 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index adbd11d26..1597ddc48 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -333,7 +333,7 @@ Table Design|Borders|Border Painter|table.borderPainter Table Layout|Table|Select Table|table.selectTable Table Layout|Table|Select Row|table.selectRow Table Layout|Table|Select Cell|table.selectCell -Table Layout|Table|View Gridlines|view.gridlines +Table Layout|Table|View Gridlines|table.viewGridlines Table Layout|Table|Properties|table.properties Table Layout|Draw|Draw Table|table.draw Table Layout|Draw|Eraser|table.eraser diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d03b51aa6..9dcec582a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -33,7 +33,14 @@ pub fn specs() -> Vec { CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(), CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), - CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)).pure(), + // The drawing grid over the page (screen only; never printed or exported). + CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)) + .params(r#"{"value"?: bool}"#) + .pure(), + // Outlines of table cells (screen only; never printed or exported). + CommandSpec::new("table.viewGridlines", "View Gridlines", "Table Layout › Table", |s, v| toggle(s, v, |x| &mut x.table_gridlines)) + .params(r#"{"value"?: bool}"#) + .pure(), CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(), CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom) .params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index f779da505..56dd6dac7 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -41,7 +41,11 @@ pub struct ViewState { pub focus_mode: bool, pub marks: bool, pub ruler: bool, + /// View › Show › Gridlines: the drawing grid over the page's text area (on screen only). pub gridlines: bool, + /// Table Layout › View Gridlines: outlines of table cells (on screen only). + #[serde(default)] + pub table_gridlines: bool, pub nav_pane: bool, pub styles_pane: bool, pub comments_pane: bool, @@ -66,6 +70,7 @@ impl Default for ViewState { marks: false, ruler: true, gridlines: false, + table_gridlines: false, nav_pane: false, styles_pane: false, comments_pane: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 973d52333..7397ff3b2 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}"); } +#[test] +fn page_and_table_gridlines_toggle_independently() { + // #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell + // outlines) are separate view switches; neither touches the document or the undo stack. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + let undo = s.undo_labels(); + assert!(!s.view.gridlines && !s.view.table_gridlines); + + assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true); + assert!(s.view.gridlines && !s.view.table_gridlines); + + // Works with the caret outside a table. + assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true); + assert!(s.view.gridlines && s.view.table_gridlines); + + assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false); + assert!(!s.view.gridlines && s.view.table_gridlines); + assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false); + assert!(!s.view.gridlines); + + assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false); + assert!(!s.view.gridlines && !s.view.table_gridlines); + + assert_eq!(text(&s), "Hello"); + assert_eq!(s.undo_labels(), undo); + let state = run(&mut s, "view.state", json!({})); + assert_eq!(state["gridlines"], false); + assert_eq!(state["tableGridlines"], false); +} + /// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom /// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck /// short of the 10%–500% limits. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1dcc4a965..d6c88b0a0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -256,6 +256,43 @@ fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 { if page.w > 0.0 { rect.width() / page.w } else { fallback } } +/// Spacing of the View › Gridlines drawing grid: 0.5 cm, in points. +const DRAWING_GRID_PT: f32 = 72.0 * 0.5 / 2.54; +/// Closest the drawing grid's lines get on screen; zoomed far out, every other line is skipped. +const DRAWING_GRID_MIN_PX: f32 = 4.0; +/// Most grid lines drawn each way on one page (page sizes come from files). +const DRAWING_GRID_MAX_LINES: usize = 2000; + +/// The View › Gridlines drawing grid over a page's text area, in document points: the x of each +/// vertical line and the y of each horizontal line, starting at the margins and `step` apart, +/// never outside the margins. Drawn on screen only — never printed or exported. +fn drawing_grid(body: wordcraft_geom::Rect, step: f32) -> (Vec, Vec) { + let axis = |start: f32, len: f32| -> Vec { + if !(step.is_finite() && step > 0.0 && start.is_finite() && len.is_finite() && len >= 0.0) { + return Vec::new(); + } + let end = start + len + 0.01; + (0..DRAWING_GRID_MAX_LINES).map(|k| start + k as f32 * step).take_while(|p| *p <= end).collect() + }; + (axis(body.x, body.w), axis(body.y, body.h)) +} + +/// The drawing grid's spacing in points at a screen scale: the default 0.5 cm, doubled until the +/// lines are at least [`DRAWING_GRID_MIN_PX`] apart on screen. +fn drawing_grid_step(scale: f32) -> f32 { + let mut step = DRAWING_GRID_PT; + if !(scale.is_finite() && scale > 0.0) { + return step; + } + for _ in 0..16 { + if step * scale >= DRAWING_GRID_MIN_PX { + break; + } + step *= 2.0; + } + step +} + pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let layout = app.session.layout(); @@ -407,8 +444,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } } - // Table gridlines. - if app.session.view.gridlines { + // View › Gridlines: the drawing grid over the text area (Print Layout, on screen only). + if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { + let (xs, ys) = drawing_grid(page.body, drawing_grid_step(page_scale)); + let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22)); + let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale); + let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale); + for x in xs { + let sx = visual_sr.min.x + x * page_scale; + painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke); + } + for y in ys { + let sy = visual_sr.min.y + y * page_scale; + painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke); + } + } + // Table Layout › View Gridlines: table cell outlines (on screen only). + if app.session.view.table_gridlines { for it in &page.items { if let Placed::Cell { rect, .. } = it { let r = Rect::from_min_size( @@ -1284,6 +1336,47 @@ mod tests { assert_eq!(visual.size(), vec2(80.5, 120.5)); } + #[test] + fn drawing_grid_starts_at_the_margins_and_stays_inside_them() { + // Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72). + let body = wordcraft_geom::Rect::new(72.0, 72.0, 468.0, 648.0); + let (xs, ys) = drawing_grid(body, DRAWING_GRID_PT); + assert_eq!(xs.first().copied(), Some(72.0)); + assert_eq!(ys.first().copied(), Some(72.0)); + assert!(xs.iter().all(|x| (72.0..=540.0).contains(x)), "{xs:?}"); + assert!(ys.iter().all(|y| (72.0..=720.0).contains(y)), "{ys:?}"); + // 0.5 cm apart, the same both ways: 468 pt holds 33 steps, 648 pt holds 45. + assert_eq!(xs.len(), 34); + assert_eq!(ys.len(), 46); + for w in xs.windows(2).chain(ys.windows(2)) { + assert!((w[1] - w[0] - DRAWING_GRID_PT).abs() < 0.01, "{w:?}"); + } + // A grid line that lands on the margin is kept. + let (xs, _) = drawing_grid(wordcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0), 25.0); + assert_eq!(xs, vec![0.0, 25.0, 50.0, 75.0, 100.0]); + } + + #[test] + fn drawing_grid_survives_hostile_sizes() { + let r = wordcraft_geom::Rect::new; + for (body, step) in [ + (r(72.0, 72.0, 468.0, 648.0), 0.0), + (r(72.0, 72.0, 468.0, 648.0), -5.0), + (r(72.0, 72.0, 468.0, 648.0), f32::NAN), + (wordcraft_geom::Rect { x: f32::NAN, y: 0.0, w: f32::INFINITY, h: -10.0 }, DRAWING_GRID_PT), + ] { + let (xs, ys) = drawing_grid(body, step); + assert!(xs.is_empty() && ys.is_empty(), "{body:?} {step}"); + } + let (xs, ys) = drawing_grid(r(0.0, 0.0, 1.0e9, 1.0e9), DRAWING_GRID_PT); + assert_eq!((xs.len(), ys.len()), (DRAWING_GRID_MAX_LINES, DRAWING_GRID_MAX_LINES)); + // Zoomed far out, lines thin out instead of filling the page. + assert_eq!(drawing_grid_step(PX_PER_PT), DRAWING_GRID_PT); + assert!(drawing_grid_step(0.1) * 0.1 >= DRAWING_GRID_MIN_PX); + assert_eq!(drawing_grid_step(0.0), DRAWING_GRID_PT); + assert_eq!(drawing_grid_step(f32::NAN), DRAWING_GRID_PT); + } + #[test] fn texel_aligned_rect_keeps_integer_pixel_pages_unchanged() { let layout = Rect::from_min_size(pos2(10.0, 20.0), vec2(80.0, 120.0)); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index f8f5c5045..f9cc4731e 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -333,7 +333,7 @@ fn controls(tab: &str) -> Vec { } "Table Layout" => vec![ m("Select", "table.selectCell"), - c("View Gridlines", "view.gridlines"), + c("View Gridlines", "table.viewGridlines"), c("Properties", "table.properties"), m("Delete", "table.deleteCells"), c("Insert Above", "table.insertRowAbove"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 396310d52..978256ef2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1293,8 +1293,8 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Select Row", "table.selectRow", json!({})); mi(ui, app, "Select Table", "table.selectTable", json!({})); }); - let g = app.session.view.gridlines; - small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g); + let g = app.session.view.table_gridlines; + small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "table.viewGridlines", json!({}), g); small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false); }); }); From d8f71959d37d8446692bc84a01d39833aa369d5d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:45:11 +0200 Subject: [PATCH 07/16] Create and modify your own table styles, saved in the .docx (#146) (#256) New Table Style and Modify Table Style (Table Design > Table Styles) make real table styles in the document's style sheet: name, based on, and formatting for the whole table, the header row and banded rows (borders, fill, bold, font colour). The table at the caret takes the new style, it shows in the gallery, and other tools that read table styles by name (pandoc's custom-style, for one) see it. - engine: table.newStyle / table.modifyStyle with documented params (regions take borders, fill, bold, italic, colour, size, font; bandSize; para); table.style only accepts table styles now. - doc/layout: table styles gain header-row and row-band cell borders, row-band character formatting and rows per band, applied by layout. - docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are read and written, so custom styles round-trip. - ui: a compact dialog from the Styles menu next to the gallery; table style tiles redraw when a style changes. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- crates/doc/src/styles.rs | 28 ++-- crates/docx/src/read/mod.rs | 11 +- crates/docx/src/write/mod.rs | 60 ++++---- crates/docx/tests/roundtrip.rs | 8 +- crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/table.rs | 6 +- crates/engine/src/cmd/table_style.rs | 183 +++++++++++++++++++++++ crates/engine/src/tests.rs | 63 ++++++++ crates/layout/src/table.rs | 36 +++-- crates/ui-egui/src/dialogs.rs | 212 ++++++++++++++++++++++++++- crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/previews.rs | 4 +- crates/ui-egui/src/ribbon.rs | 16 ++ docs/parity.md | 4 +- 21 files changed, 620 insertions(+), 59 deletions(-) create mode 100644 crates/engine/src/cmd/table_style.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2dd318199..ecc10ceff 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) | **378 / 428 (88%)** | +| Feature catalog coverage (Word ribbon/menu features with a command) | **380 / 430 (88%)** | | **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/doc/src/styles.rs b/crates/doc/src/styles.rs index 7a0e201c6..0844c9315 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -52,7 +52,16 @@ pub struct TableStyleParts { pub cell_margins: Option<[f32; 4]>, pub header_fill: Option, pub header_chr: CharProps, + /// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the + /// border between header rows, `inside_v` between its cells. + pub header_borders: Option, pub band_fill: Option, + /// Character formatting of the odd row bands (`band1Horz` region). + pub band_chr: CharProps, + /// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`). + pub band_borders: Option, + /// Rows per band (`w:tblStyleRowBandSize`); one when unset. + pub band_size: Option, pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, @@ -62,17 +71,18 @@ impl TableStyleParts { /// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins, /// the rest is inherited; borders merge edge by edge. pub fn overlay(&mut self, patch: &TableStyleParts) { - self.borders = match (self.borders, patch.borders) { - (Some(b), Some(p)) => Some(Borders { - top: p.top.or(b.top), - left: p.left.or(b.left), - bottom: p.bottom.or(b.bottom), - right: p.right.or(b.right), - between: p.between.or(b.between), - inside_v: p.inside_v.or(b.inside_v), - }), + let merge = |b: Option, p: Option| match (b, p) { + (Some(mut b), Some(p)) => { + b.overlay(&p); + Some(b) + } (b, p) => p.or(b), }; + self.borders = merge(self.borders, patch.borders); + self.header_borders = merge(self.header_borders, patch.header_borders); + self.band_borders = merge(self.band_borders, patch.band_borders); + self.band_size = patch.band_size.or(self.band_size); + self.band_chr.overlay(&patch.band_chr); self.fill = patch.fill.or(self.fill); self.cell_margins = patch.cell_margins.or(self.cell_margins); self.header_fill = patch.header_fill.or(self.header_fill); diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index d3e0b448e..bf8e29bab 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -444,9 +444,11 @@ impl Reader<'_> { } t.fill = tp.child("w:shd").and_then(props::shd_fill); t.cell_margins = tp.child("w:tblCellMar").map(props::margins); + t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000)); } - for c in s.children("w:tblStylePr") { + for c in s.children("w:tblStylePr").take(64) { let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill); + let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders); let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(); match c.attr("w:type") { Some("wholeTable") => { @@ -464,8 +466,13 @@ impl Reader<'_> { Some("firstRow") => { t.header_fill = fill; t.header_chr = chr; + t.header_borders = cell_borders; + } + Some("band1Horz") => { + t.band_fill = fill; + t.band_chr = chr; + t.band_borders = cell_borders; } - Some("band1Horz") => t.band_fill = fill, Some("firstCol") => t.first_col_chr = chr, Some("lastRow") => { t.total_chr = chr; diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 02f4f1eb5..e6a49c66e 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) { w.close("w:rPr"); } if let Some(t) = &st.table { - if t.borders.is_some() || t.cell_margins.is_some() { + if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() { w.open("w:tblPr", &[]); + if let Some(n) = t.band_size { + w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string()); + } if let Some(b) = &t.borders { props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]); } @@ -632,35 +635,40 @@ fn style_xml(w: &mut W, st: &Style) { } w.close("w:tblPr"); } - let cond = - |w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option, top: Option<&wordcraft_doc::props::Border>| { - if !props::has_rpr(chr) && fill.is_none() && top.is_none() { - return; + // One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders + // and shading. + let cond = |w: &mut W, + ty: &str, + chr: &wordcraft_doc::CharProps, + fill: Option, + borders: Option| { + if !props::has_rpr(chr) && fill.is_none() && borders.is_none() { + return; + } + w.open("w:tblStylePr", &[("w:type", ty)]); + if props::has_rpr(chr) { + w.open("w:rPr", &[]); + props::rpr_inner(w, chr); + w.close("w:rPr"); + } + if fill.is_some() || borders.is_some() { + w.open("w:tcPr", &[]); + if let Some(bs) = &borders { + props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]); } - w.open("w:tblStylePr", &[("w:type", ty)]); - if props::has_rpr(chr) { - w.open("w:rPr", &[]); - props::rpr_inner(w, chr); - w.close("w:rPr"); + if let Some(f) = fill { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); } - if fill.is_some() || top.is_some() { - w.open("w:tcPr", &[]); - if let Some(b) = top { - let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() }; - props::borders(w, "w:tcBorders", &bs, None, &[]); - } - if let Some(f) = fill { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); - } - w.close("w:tcPr"); - } - w.close("w:tblStylePr"); - }; + w.close("w:tcPr"); + } + w.close("w:tblStylePr"); + }; cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None); - cond(w, "firstRow", &t.header_chr, t.header_fill, None); - cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref()); + cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders); + let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }); + cond(w, "lastRow", &t.total_chr, None, total_top); cond(w, "firstCol", &t.first_col_chr, None, None); - cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None); + cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders); } w.close("w:style"); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 44e7715e9..e65f21b39 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -429,7 +429,8 @@ fn comments_round_trip() { } } -/// A table style's cell text formatting, whole-table shading and cell margins survive a save. +/// A table style's cell text formatting, whole-table shading, cell margins and its header row and +/// row band conditional formats (#146) survive a save. #[test] fn table_style_formatting_round_trips() { use wordcraft_doc::styles::TableStyleParts; @@ -440,6 +441,11 @@ fn table_style_formatting_round_trips() { cell_margins: Some([1.0, 14.4, 0.0, 14.4]), header_chr: CharProps { italic: Some(true), ..Default::default() }, header_fill: Some(Rgb(0xFF, 0xFF, 0)), + header_borders: Some(Borders::all(Border::single(1.5))), + band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)), + band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() }, + band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + band_size: Some(3), ..Default::default() }; d.styles.upsert(Style { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 1597ddc48..7dcc945ea 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -327,6 +327,8 @@ View|Macros|Record Macro|tools.recordMacro View|SharePoint|Properties|file.properties Table Design|Table Style Options|Table Style Options|table.look Table Design|Table Styles|Table Styles|table.style +Table Design|Table Styles|New Table Style|table.newStyle +Table Design|Table Styles|Modify Table Style|table.modifyStyle Table Design|Table Styles|Shading|table.shading Table Design|Borders|Borders|table.borders Table Design|Borders|Border Painter|table.borderPainter diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bd1962a55..42aaf7bc2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod references; pub mod review; pub mod speech; pub mod table; +pub mod table_style; pub mod text; pub mod tools; pub mod view; @@ -39,6 +40,7 @@ pub fn registry() -> Registry { v.extend(equation::specs()); v.extend(page::specs()); v.extend(table::specs()); + v.extend(table_style::specs()); v.extend(review::specs()); v.extend(file::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 837c032b8..7a6a408f9 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)), t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| { let st = p::req_str(v, "style")?; - let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; + let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; with_table(s, |t| t.props.style = Some(id.clone())) }) .params(r#"{"style": string}"#)), @@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul sel_result(s) } -/// Bump the revision of every paragraph in a table (table style changes alter their layout). /// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the /// caret cell's direction (horizontal → down → up) and gives every selected cell the result. fn text_direction(s: &mut Session, v: &Value) -> CmdResult { @@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult { with_cells(s, |cl| cl.props.text_direction = dir) } -fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { +/// Bump the revision of every paragraph in a table (table style changes alter their layout). +pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { let story = s.sel.focus.story; let paths: Vec = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect(); for p in paths { diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs new file mode 100644 index 000000000..79baae174 --- /dev/null +++ b/crates/engine/src/cmd/table_style.rs @@ -0,0 +1,183 @@ +//! Table Design › Table Styles: create and modify table styles (whole table, header row and +//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6). + +use serde_json::{Value, json}; +use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor}; +use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts}; + +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, +/// `bandedRows`) takes the same shortcuts. +macro_rules! style_params { + ($head:literal) => { + concat!( + "{", + $head, + r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#, + r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"# + ) + }; +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)), + CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style) + .params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)), + ] +} + +/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count. +pub fn table_style_id(s: &Session, name: &str) -> Option { + let st = &s.doc.styles.styles; + st.iter() + .find(|x| x.kind == StyleKind::Table && x.id == name) + .or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name))) + .map(|x| x.id.clone()) +} + +/// The style of the table at the caret. +fn current_table_style(s: &Session) -> Option { + let (tp, _, _) = s.sel.focus.path.cell()?; + s.doc.table(s.sel.focus.story, &tp)?.props.style.clone() +} + +fn new_style(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() { + return Err(CmdError::Params("name is empty".into())); + } + if s.doc.styles.find(&name).is_some() { + return Err(CmdError::Failed(format!("a style named `{name}` already exists"))); + } + let based_on = match p::str(v, "basedOn") { + Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?), + None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")), + }; + let id = s.doc.styles.new_id(&name); + let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() }; + edit(&mut st, v)?; + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(true) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +fn modify_style(s: &mut Session, v: &Value) -> CmdResult { + let id = match p::str(v, "style") { + Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?, + None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?, + }; + let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if let Some(n) = &rename + && s.doc.styles.find(n).is_some_and(|x| x.id != id) + { + return Err(CmdError::Failed(format!("a style named `{n}` already exists"))); + } + let based_on = match p::str(v, "basedOn") { + Some(b) => { + let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?; + // A style can't be based on itself or on a style based on it. + if s.doc.styles.chain(&b).iter().any(|x| x.id == id) { + return Err(CmdError::Params("a style can't be based on itself".into())); + } + Some(b) + } + None => None, + }; + let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?; + if let Some(n) = rename { + st.name = n; + } + if let Some(b) = based_on { + st.based_on = Some(b); + } + edit(&mut st, v)?; + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(false) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +/// Apply style `id` to the table at the caret, if any. +fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> { + let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) }; + s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string()); + super::table::touch_cells(s, &tp) +} + +/// Apply the formatting params to `st`. +fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { + let mut parts = st.table.take().unwrap_or_default(); + if let Some(r) = v.get("wholeTable") { + region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?; + } + if let Some(r) = v.get("headerRow") { + region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?; + } + if let Some(r) = v.get("bandedRows") { + region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?; + } + if let Some(n) = v.get("bandSize") { + parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32); + } + if let Some(pp) = v.get("para") { + let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?; + st.para.overlay(&pp); + st.para.style = None; + st.para.numbering = None; + } + st.table = (parts != TableStyleParts::default()).then_some(parts); + Ok(()) +} + +/// One region's shortcuts: borders, fill and character formatting. +fn region(r: &Value, fill: &mut Option, chr: &mut CharProps, borders: &mut Option) -> Result<(), CmdError> { + if !r.is_object() { + return Err(CmdError::Params("a region is an object".into())); + } + let color = |k: &str| -> Result, CmdError> { + match r.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))), + Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))), + } + }; + if r.get("fill").is_some() { + *fill = color("fill")?; + } + if r.get("color").is_some() { + chr.color = color("color")?.map(TextColor::Rgb); + } + if let Some(b) = p::bool(r, "bold") { + chr.bold = Some(b); + chr.bold_cs = Some(b); + } + if let Some(b) = p::bool(r, "italic") { + chr.italic = Some(b); + chr.italic_cs = Some(b); + } + if let Some(sz) = p::f32(r, "size") { + chr.size = Some(sz.clamp(1.0, 1638.0)); + chr.size_cs = chr.size; + } + if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) { + chr.font = Some(f.to_string()); + } + match r.get("borders") { + Some(Value::Bool(true)) => { + let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0); + *borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 })); + } + Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + Some(Value::Null) => *borders = None, + Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())), + None => {} + } + Ok(()) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7397ff3b2..68ae8fc75 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1701,6 +1701,69 @@ fn timestamps_come_from_the_clock() { assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } +/// #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] +fn custom_table_style_applies_modifies_and_saves() { + use wordcraft_doc::{Rgb, StyleKind, TextColor}; + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "Head"})); + run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + let r = run( + &mut s, + "table.newStyle", + json!({"name": "Thesis Table", "basedOn": "Table Grid", + "wholeTable": {"size": 10}, + "headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true}, + "bandedRows": {"fill": "EEEEEE", "italic": true}}), + ); + let id = r["id"].as_str().unwrap().to_string(); + let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone(); + assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table"); + assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique"); + assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles"); + assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables"); + + let fills = |s: &mut Session, c: Rgb| { + s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count() + }; + let head = |s: &mut Session| { + let mut p = tp.0.clone(); + p.extend([0, 0, 0]); + s.layout().pages[0] + .items + .iter() + .find_map( + |i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None }, + ) + .unwrap() + }; + assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells"); + assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band"); + let rc = head(&mut s); + assert!(rc.bold && rc.size == 10.0, "{rc:?}"); + assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE)); + + // Modify the current table's style: the table follows. + run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2})); + assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0); + assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2); + assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band"); + assert!(!head(&mut s).bold); + + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let st = back.styles.get(&id).unwrap(); + assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid"))); + let parts = st.table.as_ref().unwrap(); + assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2))); + assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true))); + assert!(parts.header_borders.is_some_and(|b| b.any_visible())); + let t = back.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle"); +} + #[test] fn page_and_table_gridlines_toggle_independently() { // #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 491a4ef95..6f5cc42b0 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -40,8 +40,8 @@ struct Turned { across: f32, /// List counters and equation count before the cell, to lay it out again the same way. snap: (Counters, u32), - /// Its formatting region (header row, total row, first column) and cell index in the row. - region: (bool, bool, bool), + /// Its formatting region (header row, total row, first column, row band) and cell index in the row. + region: (bool, bool, bool, bool), ci: usize, /// Top margin plus top border band. top: f32, @@ -97,8 +97,9 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout { let style = table_style(ctx, t); let parts = style.as_ref().map(|s| &s.parts); - // Cell text formatting per (header row, total row, first column) region, built once per table. - let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new(); + // Cell text formatting per (header row, total row, first column, row band) region, built once + // per table. + let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new(); let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { @@ -152,6 +153,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava }; let nrows = t.rows.len(); let header_rows = t.props.look.header_row; + let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize; // First pass: lay out every cell's content. struct CellBox { items: Vec, @@ -178,7 +180,16 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut row_insets = 0.0f32; let is_header = header_rows && ri == 0; let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; - let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0; + let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2); + // The header row's or the odd band's own cell borders (conditional formatting). + let region_borders = if is_header { + parts.and_then(|p| p.header_borders) + } else if band { + parts.and_then(|p| p.band_borders) + } else { + None + } + .unwrap_or_default(); for (ci, cell) in row.cells.iter().enumerate() { let span = cell.span(); let x0 = colx.get(g).copied().unwrap_or(acc); @@ -186,7 +197,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let margins = cell.props.margins.unwrap_or(margins_def); let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; - let region = (is_header, is_total, t.props.look.first_column && g == 0); + let region = (is_header, is_total, t.props.look.first_column && g == 0, band); if let Some(p) = parts { if is_header { fill = fill.or(p.header_fill); @@ -202,6 +213,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // The whole table's formatting, then column, then row conditional formatting // (later regions win, ECMA-376 §17.7.6). let mut chr = st.chr.clone(); + if band { + chr.overlay(&st.parts.band_chr); + } if region.2 { chr.overlay(&st.parts.first_col_chr); } @@ -221,11 +235,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let edge = |own: Option, outer: bool, outer_b: Option, inner_b: Option| own.or(if outer { outer_b } else { inner_b }); let cb = cell.props.borders.unwrap_or_default(); + let rb = region_borders; + let (first_g, last_g) = (g == 0, g + span >= ncols); let mut borders = Borders { - top: edge(cb.top, ri == 0, tb.top, tb.between), - bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between), - left: edge(cb.left, g == 0, tb.left, tb.inside_v), - right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v), + top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between), + bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between), + left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v), + right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v), between: None, inside_v: None, }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 259eceb1e..c9a211471 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,8 +1,8 @@ //! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, -//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About, Save Changes, and the -//! mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, Match Fields and -//! Check for Errors. Every dialog ends by running a command (or shows one's result), so agents get -//! the same result without the dialog. +//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, About, +//! Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, +//! Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's +//! result), so agents get the same result without the dialog. use egui::{Sense, Ui, vec2}; use serde::Serialize; @@ -101,6 +101,21 @@ pub enum Dialog { before: f32, after: f32, }, + /// Table Design › New / Modify Table Style: one formatting region at a time. + TableStyle { + /// The style being modified; `None` creates one. + id: Option, + name: String, + /// Style id. + based_on: String, + /// Index into `regions`: whole table, header row, banded rows. + region: usize, + regions: Box<[TableRegion; 3]>, + /// What the regions showed when the dialog opened (or the base style changed): only + /// changes are sent, so everything else stays inherited. + #[serde(skip)] + basis: Box<[TableRegion; 3]>, + }, Commands { query: String, }, @@ -211,6 +226,93 @@ pub fn recipient_table(fields: &[String], rows: &[Vec]) -> Result Box<[TableRegion; 3]> { + let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() }; + let p = &st.parts; + let hex = |c: Option| c.map(|c| c.hex()).unwrap_or_default(); + let text = |c: &wordcraft_doc::CharProps| match c.color { + Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(), + _ => String::new(), + }; + let lines = |b: Option| b.is_some_and(|b| b.any_visible()); + let mut header_chr = st.chr.clone(); + header_chr.overlay(&p.header_chr); + let mut band_chr = st.chr.clone(); + band_chr.overlay(&p.band_chr); + Box::new([ + TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) }, + TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) }, + TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) }, + ]) +} + +/// The style of the table at the caret, when it is a table style. +fn current_table_style(app: &WordApp) -> Option { + let s = &app.session; + let (tp, _, _) = s.sel.focus.path.cell()?; + let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?; + s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone()) +} + +/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region. +fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { + let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) }; + let mut v = json!({}); + if r.borders != basis.borders { + v["borders"] = json!(r.borders); + } + if r.fill != basis.fill { + v["fill"] = color(&r.fill); + } + if r.bold != basis.bold { + v["bold"] = json!(r.bold); + } + if r.color != basis.color { + v["color"] = color(&r.color); + } + v +} + +/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none. +fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) { + let c = wordcraft_doc::Rgb::parse(value); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + let t = Tokens::get(ui.ctx()); + match c { + Some(c) => { + ui.painter().rect_filled(r, 2.0, crate::theme::c32(c)); + } + None => { + ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim)); + } + } + ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() }; + ui.menu_button(label, |ui| { + if ui.button(tl!("No Color")).clicked() { + value.clear(); + ui.close(); + } + if let Some(hex) = crate::widgets::color_grid(ui, theme) { + *value = hex; + ui.close(); + } + }); + }); +} + impl Dialog { pub fn name(&self) -> &'static str { match self { @@ -228,6 +330,8 @@ impl Dialog { Dialog::Watermark { .. } => "watermark", Dialog::NewStyle { .. } => "newStyle", Dialog::ModifyStyle { .. } => "modifyStyle", + Dialog::TableStyle { id: None, .. } => "newTableStyle", + Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::Commands { .. } => "commands", Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", @@ -327,6 +431,20 @@ impl Dialog { "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, "watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true }, "newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() }, + "newTableStyle" => { + let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into()); + let styles = &app.session.doc.styles; + let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default(); + let regions = table_regions(app, &based_on); + Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions } + } + "modifyTableStyle" => { + let id = current_table_style(app)?; + let st = app.session.doc.styles.get(&id)?; + let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default()); + let regions = table_regions(app, &id); + Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } + } "commands" => Dialog::Commands { query: String::new() }, "about" => Dialog::About { tab: 0 }, "contributors" => Dialog::About { tab: 1 }, @@ -449,6 +567,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::Watermark { .. } => "Custom Watermark", Dialog::NewStyle { .. } => "Create New Style", Dialog::ModifyStyle { .. } => "Modify Style", + Dialog::TableStyle { id: None, .. } => "New Table Style", + Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", @@ -883,6 +1003,90 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + Dialog::TableStyle { id, name, based_on, region, regions, basis } => { + let styles: Vec<(String, String)> = app + .session + .doc + .styles + .styles + .iter() + .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref()) + .map(|s| (s.id.clone(), s.name.clone())) + .collect(); + let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); + let mut rebased = false; + egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Name:")); + ui.text_edit_singleline(name); + ui.end_row(); + ui.label(tl!("Style based on:")); + egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { + for (sid, sname) in &styles { + if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { + *based_on = sid.clone(); + rebased = true; + } + } + }); + ui.end_row(); + ui.label(tl!("Apply formatting to:")); + let names = ["Whole Table", "Header Row", "Banded Rows"]; + egui::ComboBox::from_id_salt("tstyle_region") + .width(200.0) + .selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table"))) + .show_ui(ui, |ui| { + for (i, n) in names.iter().enumerate() { + ui.selectable_value(region, i, tl!(n)); + } + }); + ui.end_row(); + }); + // A new style shows its new base's formatting; a modified one keeps its own. + if rebased && id.is_none() { + *regions = table_regions(app, based_on); + *basis = regions.clone(); + } + ui.separator(); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(r) = regions.get_mut(*region) { + ui.horizontal(|ui| { + ui.checkbox(&mut r.borders, tl!("Borders")); + ui.checkbox(&mut r.bold, tl!("Bold")); + }); + egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Fill color:")); + color_menu(ui, &theme, &mut r.fill); + ui.end_row(); + ui.label(tl!("Font color:")); + color_menu(ui, &theme, &mut r.color); + ui.end_row(); + }); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + let mut v = json!({"name": name.trim(), "basedOn": based_on}); + for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() { + if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) { + let ch = region_changes(r, b); + if ch.as_object().is_some_and(|o| !o.is_empty()) { + v[key] = ch; + } + } + } + let cmd = match id { + Some(sid) => { + v["style"] = json!(sid); + "table.modifyStyle" + } + None => "table.newStyle", + }; + if let Err(e) = app.run(cmd, v) { + app.status(e); + return false; + } + } + ok || cancel + } Dialog::Commands { query } => { let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0)); r.request_focus(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ef46ecfb..19de3af08 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -948,3 +948,10 @@ Dark Oscuro Use system setting Usar la configuración del sistema A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto. + New Table Style Nuevo estilo de tabla + Modify Table Style Modificar estilo de tabla + New Table Style… Nuevo estilo de tabla… + Modify Table Style… Modificar estilo de tabla… + Apply formatting to: Aplicar formato a: + Whole Table Toda la tabla + Fill color: Color de relleno: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a73def00d..4acca7de1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,3 +931,10 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + New Table Style 新しい表スタイル + Modify Table Style 表スタイルの変更 + New Table Style… 新しい表スタイル… + Modify Table Style… 表スタイルの変更… + Apply formatting to: 書式の適用先: + Whole Table 表全体 + Fill color: 塗りつぶしの色: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 921391be9..888924580 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,3 +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. + New Table Style Novo estilo de tabela + Modify Table Style Modificar estilo de tabela + New Table Style… Novo estilo de tabela… + Modify Table Style… Modificar estilo de tabela… + Apply formatting to: Aplicar formatação a: + Whole Table Tabela inteira + Fill color: Cor de preenchimento: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aeff3d2da..17b84bd9e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -943,3 +943,10 @@ Dark Темна Use system setting Як у системі A file dialog is already open. Діалогове вікно файлу вже відкрито. + New Table Style Новий стиль таблиці + Modify Table Style Змінити стиль таблиці + New Table Style… Новий стиль таблиці… + Modify Table Style… Змінити стиль таблиці… + Apply formatting to: Застосувати форматування до: + Whole Table Уся таблиця + Fill color: Колір заливки: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b2f9d3cba..35b7f9065 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,3 +942,10 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + New Table Style 新建表格样式 + Modify Table Style 修改表格样式 + New Table Style… 新建表格样式… + Modify Table Style… 修改表格样式… + Apply formatting to: 将格式应用于: + Whole Table 整个表格 + Fill color: 填充颜色: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a46dcca06..58c2d70f2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,3 +932,10 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + New Table Style 新增表格樣式 + Modify Table Style 修改表格樣式 + New Table Style… 新增表格樣式… + Modify Table Style… 修改表格樣式… + Apply formatting to: 將格式套用至: + Whole Table 整個表格 + Fill color: 填滿色彩: diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 95d8da794..e6d844a35 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -329,7 +329,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - let key = format!("tstyle:{style}:{ppp}"); + // Keyed by the style sheet too, so a modified table style redraws. + let skey = app.previews.styles_key(&app.session.doc, app.session.rev()); + let key = format!("tstyle:{style}:{skey}:{ppp}"); let doc = &app.session.doc; let tex = app.previews.get_or(ui.ctx(), &key, || { let mut tb = Table::new(5, 4, 100.0); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 978256ef2..056898523 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1257,6 +1257,22 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { }); }); stack(ui, |ui| { + let can_modify = look.is_some() + && app + .session + .sel + .focus + .path + .cell() + .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()) + .is_some(); + menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| { + mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); + ui.close(); + } + }); split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { mi(ui, app, "No Color", "table.shading", json!({"color": null})); let theme = app.session.doc.settings.theme_colors.clone(); diff --git a/docs/parity.md b/docs/parity.md index afb626964..2de2ef497 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ 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. -**378 of 428 catalog features have commands (88.3%).** +**380 of 430 catalog features have commands (88.4%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Mailings | 17 | 17 | | Review | 26 | 29 | | View | 25 | 29 | -| Table Design | 5 | 5 | +| Table Design | 7 | 7 | | Table Layout | 28 | 30 | | Equation | 21 | 22 | | Picture Format | 13 | 13 | From 9c3ad84d337a5f0d37ce42cb70d7504483996155 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 19:45:35 +0200 Subject: [PATCH 08/16] Advanced Find: every match, Reading Highlight, search within a selection (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- crates/engine/src/cmd/edit.rs | 36 ++++++++++++++++++++++++++++- crates/engine/src/session.rs | 17 ++++++++++++++ crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 11 +++++++++ crates/ui-egui/src/dialogs.rs | 16 ++++++++++++- crates/ui-egui/src/i18n/es.tsv | 5 ++++ crates/ui-egui/src/i18n/ja.tsv | 5 ++++ crates/ui-egui/src/i18n/pt-br.tsv | 5 ++++ crates/ui-egui/src/i18n/uk.tsv | 5 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 ++++ crates/ui-egui/src/i18n/zh-hant.tsv | 5 ++++ crates/ui-egui/src/ribbon.rs | 6 ++++- crates/ui-egui/src/theme.rs | 4 ++++ docs/parity.md | 5 ++-- 15 files changed, 151 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ecc10ceff..b9a31ea9d 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 1a86a758c..b24616b43 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 56dd6dac7..d37454809 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 68ae8fc75..0e713dfae 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 d6c88b0a0..5c87d4000 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 c9a211471..16b7255e9 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 19de3af08..88f3a5760 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 4acca7de1..a1ed376ae 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 888924580..318240f2a 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 17b84bd9e..b59e8baf7 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 35b7f9065..7faf9d4a5 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 58c2d70f2..da88b5f04 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 056898523..c99b9a4f0 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 23276d12f..a30a090f8 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 2de2ef497..279962e8f 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 From 8bc4e6c999ca0ee9b2a32d1dd0993effa65928e5 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 19:46:21 +0200 Subject: [PATCH 09/16] Paste Special: paste the clipboard as formatted text, RTF, HTML or unformatted text (edit.pasteSpecial) (#235) New edit.pasteSpecial command ({"as"?, "text"?, "html"?, "rtf"?}). Without "as" it lists the clipboard's formats and asks the front end for the dialog; with "as" it pastes in that format. HTML/RTF go through the wordcraft-formats importers, with their lists, styles and images merged into the document under fresh ids. Respects Track Changes (now also inside pasted tables) and caps payloads at 16 MB. UI: Paste Special dialog (radio list + result hint), Mod+Alt+V and Paste menu entry; strings in all four catalogs. Parity 355/405, floor 87.5%. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/catalog.rs | 2 +- crates/engine/src/cmd/edit.rs | 20 +-- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/paste.rs | 215 ++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 78 ++++++++++ crates/ui-egui/src/dialogs.rs | 59 +++++++- crates/ui-egui/src/i18n/es.tsv | 13 ++ crates/ui-egui/src/i18n/ja.tsv | 13 ++ crates/ui-egui/src/i18n/pt-br.tsv | 13 ++ crates/ui-egui/src/i18n/uk.tsv | 13 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 13 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 13 ++ crates/ui-egui/src/keys.rs | 8 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 1 + docs/parity.md | 5 +- 17 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/paste.rs diff --git a/ROADMAP.md b/ROADMAP.md index b9a31ea9d..61b18c7a1 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) | **422** | -| Feature catalog coverage (Word ribbon/menu features with a command) | **381 / 430 (89%)** | +| Commands (every action, scriptable by CLI/MCP/control channel) | **427** | +| Feature catalog coverage (Word ribbon/menu features with a command) | **382 / 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/catalog.rs b/crates/engine/src/catalog.rs index 7dcc945ea..687790a3f 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -502,6 +502,6 @@ mod tests { let p = parity(®); let pct = p["percent"].as_f64().unwrap(); // The floor only ever rises. - assert!(pct >= 60.0, "parity {pct}%"); + assert!(pct >= 87.5, "parity {pct}%"); } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b24616b43..fc172124e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; -use wordcraft_doc::{Block, Pos, StoryRef}; +use wordcraft_doc::{Pos, StoryRef}; use super::{delete_selection, pos_json, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -84,23 +84,7 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult { (None, Some(f)) => f.clone(), (None, None) => return Err(CmdError::Failed("the clipboard is empty".into())), }; - if let Some(images) = v.get("image").and_then(Value::as_str) { - let _ = images; - } - let at = delete_selection(s)?; - let mut frag = frag; - if s.doc.settings.track_changes { - let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert); - for b in &mut frag.blocks { - if let Block::Para(p) = b { - for r in &mut p.runs { - r.props.ins = Some(rid); - } - } - } - } - let end = s.doc.insert_fragment(&at, &frag)?; - s.sel = Selection::caret(end); + super::paste::insert(s, frag)?; sel_result(s) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 42aaf7bc2..6eefb9b98 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod mailings; pub mod objects; pub mod page; pub mod para; +pub mod paste; pub mod references; pub mod review; pub mod speech; @@ -33,6 +34,7 @@ pub fn registry() -> Registry { v.extend(text::specs()); v.extend(caret::specs()); v.extend(edit::specs()); + v.extend(paste::specs()); v.extend(format::specs()); v.extend(para::specs()); v.extend(view::specs()); diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs new file mode 100644 index 000000000..ad1ba099d --- /dev/null +++ b/crates/engine/src/cmd/paste.rs @@ -0,0 +1,215 @@ +//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted). +//! +//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps +//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end +//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source +//! can also be pasted as that format. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use wordcraft_doc::edit::Fragment; +use wordcraft_doc::{Block, Document, Paragraph}; + +use super::{delete_selection, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +/// Largest clipboard payload Paste Special accepts, in bytes. +pub const MAX_PASTE_BYTES: usize = 16 << 20; +/// Nesting depth we follow when adjusting pasted tables. +const MAX_DEPTH: usize = 64; + +/// The formats, in the order the dialog lists them: (id, label). +pub const FORMATS: [(&str, &str); 4] = + [("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")]; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special) + .key("Mod+Alt+V") + .params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#), + ] +} + +/// Clipboard text from the params (system clipboard), else WordCraft's own copy. +fn clip_text(s: &Session, v: &Value) -> Option { + match p::str(v, "text") { + Some(t) => Some(t.replace("\r\n", "\n")), + None => s.clipboard.as_ref().map(Fragment::plain_text), + } +} + +fn looks_like_rtf(t: &str) -> bool { + t.trim_start().starts_with("{\\rtf") +} + +fn looks_like_html(t: &str) -> bool { + let t = t.trim_start(); + t.starts_with('<') && (t.contains("")) +} + +/// The source for a format, if the clipboard has it. +fn source(s: &Session, v: &Value, fmt: &str) -> Option { + let non_empty = |t: String| if t.is_empty() { None } else { Some(t) }; + match fmt { + "formatted" => { + let f = s.clipboard.as_ref()?; + // System clipboard text that isn't our own copy means someone else copied since. + match p::str(v, "text") { + Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None, + _ => Some(String::new()), + } + } + "rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty), + "html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty), + "text" => clip_text(s, v).and_then(non_empty), + _ => None, + } +} + +/// Formats the clipboard can be pasted as, best first. +pub fn available(s: &Session, v: &Value) -> Vec<&'static str> { + FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect() +} + +fn formats_json(ids: &[&str]) -> Value { + json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::>()) +} + +fn paste_special(s: &mut Session, v: &Value) -> CmdResult { + for k in ["text", "html", "rtf"] { + if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) { + return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20))); + } + } + let ids = available(s, v); + let Some(fmt) = p::str(v, "as") else { + // No format chosen: list them and ask a front end (if any) to show the dialog. + let mut req = json!({"open": "pasteSpecial", "formats": ids}); + for k in ["text", "html", "rtf"] { + if let Some(t) = p::str(v, k) { + req[k] = json!(t); + } + } + s.ui_requests.push(req); + return Ok(json!({"formats": formats_json(&ids)})); + }; + let fmt = match fmt.to_ascii_lowercase().as_str() { + "formatted" | "keep" | "wordcraft" => "formatted", + "rtf" => "rtf", + "html" => "html", + "text" | "unformatted" | "plain" => "text", + other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))), + }; + let Some(src) = source(s, v, fmt) else { + return Err(CmdError::Failed(if ids.is_empty() { + "the clipboard is empty".into() + } else { + format!("the clipboard has no {fmt} content (available: {})", ids.join(", ")) + })); + }; + let frag = match fmt { + "text" => { + super::type_text(s, &src.replace('\n', "\r"))?; + let mut r = sel_result(s)?; + r["pastedAs"] = json!(fmt); + return Ok(r); + } + "formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?, + "html" => adopt(s, wordcraft_formats::html::import(&src)), + _ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?), + }; + insert(s, frag)?; + let mut r = sel_result(s)?; + r["pastedAs"] = json!(fmt); + Ok(r) +} + +/// Replace the selection with `frag`, marking it as an insertion under Track Changes. +pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { + let at = delete_selection(s)?; + if s.doc.settings.track_changes { + let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert); + for b in &mut frag.blocks { + each_para(b, 0, &mut |p| { + for r in &mut p.runs { + r.props.ins = Some(rid); + r.props.del = None; + } + }); + } + } + let end = s.doc.insert_fragment(&at, &frag)?; + s.sel = Selection::caret(end); + Ok(()) +} + +/// Every paragraph in a block, tables included (bounded depth). +fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { + if depth > MAX_DEPTH { + return; + } + match b { + Block::Para(p) => f(p), + Block::Table(t) => { + for row in &mut t.rows { + for cell in &mut row.cells { + for cb in &mut cell.blocks { + each_para(Arc::make_mut(cb), depth + 1, f); + } + } + } + } + } +} + +/// Turn an imported document into a fragment of the session's document: bring over the media, +/// lists and styles it refers to (renumbered so they can't collide with ours). +fn adopt(s: &mut Session, src: Document) -> Fragment { + let mut media = BTreeMap::new(); + for (key, data) in &src.media { + let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); + media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext)); + } + let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); + let mut abs_map = BTreeMap::new(); + for (i, a) in src.numbering.abstracts.iter().enumerate() { + let id = next_abs.saturating_add(i as u32); + abs_map.insert(a.id, id); + s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() }); + } + let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); + let mut num_map = BTreeMap::new(); + for (i, n) in src.numbering.nums.iter().enumerate() { + let id = next_num.saturating_add(i as u32); + num_map.insert(n.id, id); + let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); + s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() }); + } + for st in &src.styles.styles { + if s.doc.styles.get(&st.id).is_none() { + s.doc.styles.upsert(st.clone()); + } + } + let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + for b in &mut blocks { + each_para(b, 0, &mut |p| { + if let Some(n) = p.props.numbering.as_mut() + && n.num != 0 + { + n.num = num_map.get(&n.num).copied().unwrap_or(0); + } + for o in &mut p.objects { + if let wordcraft_doc::InlineObject::Image { media: key, .. } = o + && let Some(k) = media.get(key.as_str()) + { + *key = k.clone(); + } + } + }); + } + // Text box stories the imported shapes show; pasting gives each a new story of its own. + let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect(); + Fragment { blocks, parts } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e713dfae..fb50dab5e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -523,6 +523,84 @@ fn clipboard_round_trip() { assert_eq!(text(&s), "plain\ntext"); } +fn bold_at(s: &Session, block: usize, off: usize) -> bool { + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off }; + s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false) +} + +#[test] +fn paste_special_lists_formats_and_pastes_text_without_formatting() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold words"})); + run(&mut s, "select.text", json!({"text": "Bold"})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + // No format: the formats are listed (and a front end is asked to show the dialog). + let r = run(&mut s, "edit.pasteSpecial", json!({})); + let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["formatted", "text"]); + assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial"); + run(&mut s, "caret.docEnd", json!({})); + let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"})); + assert_eq!(r["pastedAs"], "text"); + assert_eq!(text(&s), "Bold wordsBold"); + assert!(bold_at(&s, 0, 0)); + assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold"); + // Formatted keeps it. + run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"})); + assert_eq!(text(&s), "Bold wordsBoldBold"); + assert!(bold_at(&s, 0, 15)); + // Text that someone else copied since: our rich copy is no longer offered. + let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"})); + assert_eq!(r["formats"].as_array().unwrap().len(), 1); + assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err()); +} + +#[test] +fn paste_special_html_and_rtf_keep_bold() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + run(&mut s, "caret.docEnd", json!({})); + let html = "

plain strong

"; + let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"})); + let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["html", "text"]); + run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html})); + assert_eq!(text(&s), "xplain strong"); + assert!(!bold_at(&s, 0, 2)); + assert!(bold_at(&s, 0, 8), "the run stays bold"); + // HTML source copied as text can be pasted as HTML too. + run(&mut s, "document.setText", json!({"text": ""})); + run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "hi there

"})); + assert_eq!(text(&s), "hi there"); + assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4)); + // RTF. + run(&mut s, "document.setText", json!({"text": ""})); + run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"})); + assert!(text(&s).starts_with("bold"), "{}", text(&s)); + assert!(bold_at(&s, 0, 0)); + // Asking for a format the clipboard lacks is an error, not a panic. + assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err()); + assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err()); + let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1); + assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err()); +} + +#[test] +fn paste_special_respects_track_changes_and_brings_lists() { + let mut s = s(); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "
  • one
  • two
"})); + assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s)); + let ch = run(&mut s, "review.changes", json!({})); + assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion"); + let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); + let n = first.props.numbering.expect("list kept"); + assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along"); + run(&mut s, "review.acceptAll", json!({})); + assert!(text(&s).starts_with("one\ntwo")); +} + #[test] fn tables_commands() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 16b7255e9..7268b302d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,8 +1,8 @@ //! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, -//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, About, -//! Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, -//! Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's -//! result), so agents get the same result without the dialog. +//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, Paste +//! Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find +//! Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by running a command +//! (or shows one's result), so agents get the same result without the dialog. use egui::{Sense, Ui, vec2}; use serde::Serialize; @@ -119,6 +119,14 @@ pub enum Dialog { Commands { query: String, }, + /// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out + /// of the serialized dialog state (it can be megabytes). + PasteSpecial { + formats: Vec, + choice: usize, + #[serde(skip)] + payload: Value, + }, /// Tabs: 0 About, 1 Contributors, 2 Models. About { tab: u8, @@ -333,6 +341,7 @@ impl Dialog { Dialog::TableStyle { id: None, .. } => "newTableStyle", Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::Commands { .. } => "commands", + Dialog::PasteSpecial { .. } => "pasteSpecial", Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", Dialog::RecipientList { .. } => "recipientList", @@ -446,6 +455,7 @@ impl Dialog { Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } } "commands" => Dialog::Commands { query: String::new() }, + "pasteSpecial" => Dialog::paste_special(app, &json!({})), "about" => Dialog::About { tab: 0 }, "contributors" => Dialog::About { tab: 1 }, "models" => Dialog::About { tab: 2 }, @@ -484,6 +494,18 @@ impl Dialog { }) } + /// Paste Special for a clipboard payload (`text`, `html`, `rtf`; empty = WordCraft's own copy). + pub fn paste_special(app: &WordApp, payload: &Value) -> Dialog { + let formats: Vec = wordcraft_engine::cmd::paste::available(&app.session, payload).into_iter().map(str::to_string).collect(); + let mut kept = json!({}); + for k in ["text", "html", "rtf"] { + if let Some(t) = payload.get(k).and_then(Value::as_str) { + kept[k] = json!(t); + } + } + Dialog::PasteSpecial { formats, choice: 0, payload: kept } + } + pub fn modify_style(app: &WordApp, id: &str) -> Option { let st = app.session.doc.styles.get(id)?; let rc = app.session.doc.styles.resolve_char( @@ -570,6 +592,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::TableStyle { id: None, .. } => "New Table Style", Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", + Dialog::PasteSpecial { .. } => "Paste Special", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", Dialog::RecipientList { .. } => "Recipient List", @@ -1130,6 +1153,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } close || ui.input(|i| i.key_pressed(egui::Key::Escape)) } + Dialog::PasteSpecial { formats, choice, payload } => { + ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5))); + if formats.is_empty() { + ui.label(egui::RichText::new(tl!("The clipboard is empty.")).weak()); + } + for (i, f) in formats.iter().enumerate() { + let label = wordcraft_engine::cmd::paste::FORMATS.iter().find(|(id, _)| id == f).map(|(_, l)| *l).unwrap_or(f.as_str()); + ui.radio_value(choice, i, tl!(label)); + } + if let Some(f) = formats.get(*choice) { + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Result")).font(semibold(12.5))); + let hint = match f.as_str() { + "formatted" => "Keeps the fonts and formatting of the copied text.", + "rtf" => "Reads the clipboard's rich text and keeps its formatting.", + "html" => "Reads the clipboard's web content and keeps its formatting.", + _ => "Drops all formatting and pastes plain text.", + }; + ui.label(egui::RichText::new(tl!(hint)).weak()); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok && let Some(f) = formats.get(*choice) { + let mut params = payload.clone(); + params["as"] = json!(f); + let _ = app.run("edit.pasteSpecial", params); + } + ok || cancel + } Dialog::About { tab } => { ui.set_width(660.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 88f3a5760..55cd1f7fc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -216,6 +216,7 @@ Drawing Tools Herramientas de dibujo Drawing\nCanvas Lienzo de\ndibujo Drop Cap Letra capital + Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato. Edit Footer Editar pie de página Edit Header Editar encabezado Edit Recipient List Editar lista de destinatarios @@ -271,6 +272,7 @@ Format Page Numbers Formato de números de página Format Page Numbers… Formato de números de página… Format Painter Pincel de formato + Formatted Text Texto con formato Formatting at Selection Formato en la selección Formula Fórmula General General @@ -284,6 +286,7 @@ Gridlines Líneas de cuadrícula Group Agrupar Grow Font 1 Point Aumentar fuente 1 punto + HTML Format Formato HTML Hanging Indent Sangría francesa Header Encabezado Header & Footer Encabezado y pie de página @@ -353,6 +356,7 @@ Keep lines together Mantener líneas juntas Keep with Next Mantener con el siguiente Keep with next Mantener con el siguiente + Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado. Labels Etiquetas Landscape Horizontal Language Idioma @@ -497,6 +501,9 @@ Paste Pegar Paste (⌘V) Pegar (⌘V) Paste Formatting Pegar formato + Paste Special Pegado especial + Paste Special… Pegado especial… + Paste as: Pegar como: Paste: Keep Text Only Pegar: mantener solo el texto Paste: Merge Formatting Pegar: combinar formato Pen Pluma @@ -530,6 +537,8 @@ Read Mode Modo lectura Read\nAloud Leer en\nvoz alta Read\nMode Modo\nlectura + Reads the clipboard's rich text and keeps its formatting. Lee el texto enriquecido del portapapeles y conserva su formato. + Reads the clipboard's web content and keeps its formatting. Lee el contenido web del portapapeles y conserva su formato. Reading Highlight Resaltado de lectura Recent Recientes Record Macro Grabar macro @@ -568,12 +577,14 @@ Restart at 1 Reiniciar en 1 Restrict Editing Restringir edición Restrict\nEditing Restringir\nedición + Result Resultado Results Resultados Reverse the order Invertir el orden Reviewing Revisión Reviewing Pane Panel de revisión Reviewing — edits become suggestions Revisión — las ediciones pasan a ser sugerencias Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf) + Rich Text Format (RTF) Formato de texto enriquecido (RTF) Right Derecha Right Border Borde derecho Right-to-Left Text Direction Dirección de texto de derecha a izquierda @@ -712,6 +723,7 @@ Text: Texto: Text\nBox Cuadro de\ntexto Text\nDirection Dirección del\ntexto + The clipboard is empty. El portapapeles está vacío. The new style takes the formatting of the current paragraph. El nuevo estilo toma el formato del párrafo actual. Theme Colors Colores del tema Theme Fonts Fuentes del tema @@ -739,6 +751,7 @@ Underline Words Only Subrayar solo las palabras Undo Deshacer Undo {action} Deshacer {action} + Unformatted Text Texto sin formato Up Arriba Update Field Actualizar campo Update Index Actualizar índice diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a1ed376ae..c985bc82a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,6 +931,19 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + Paste Special 形式を選択して貼り付け + Paste Special… 形式を選択して貼り付け… + Paste as: 貼り付ける形式: + The clipboard is empty. クリップボードは空です。 + Formatted Text 書式付きテキスト + Rich Text Format (RTF) リッチ テキスト形式 (RTF) + HTML Format HTML 形式 + Unformatted Text 書式なしテキスト + Result 結果 + Keeps the fonts and formatting of the copied text. コピーしたテキストのフォントと書式を保持します。 + Reads the clipboard's rich text and keeps its formatting. クリップボードの RTF を読み込み、書式を保持します。 + Reads the clipboard's web content and keeps its formatting. クリップボードの Web コンテンツを読み込み、書式を保持します。 + Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。 Advanced Find 高度な検索 Advanced Find… 高度な検索… Go To… ジャンプ… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 318240f2a..410d82b5f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,6 +943,19 @@ 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. + Paste Special Colar especial + Paste Special… Colar especial… + Paste as: Colar como: + The clipboard is empty. A área de transferência está vazia. + Formatted Text Texto formatado + Rich Text Format (RTF) Rich Text Format (RTF) + HTML Format Formato HTML + Unformatted Text Texto sem formatação + Result Resultado + Keeps the fonts and formatting of the copied text. Mantém as fontes e a formatação do texto copiado. + Reads the clipboard's rich text and keeps its formatting. Lê o rich text da área de transferência e mantém a formatação. + Reads the clipboard's web content and keeps its formatting. Lê o conteúdo web da área de transferência e mantém a formatação. + Drops all formatting and pastes plain text. Remove toda a formatação e cola texto simples. Advanced Find Localização avançada Advanced Find… Localização avançada… Go To… Ir para… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b59e8baf7..f10005400 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -217,6 +217,7 @@ Drawing Tools Знаряддя малювання Drawing\nCanvas Полотно\nмалювання Drop Cap Буквиця + Drops all formatting and pastes plain text. Відкидає все форматування й вставляє звичайний текст. Edit Footer Редагувати нижній колонтитул Edit Header Редагувати верхній колонтитул Edit Recipient List Редагувати список одержувачів @@ -273,6 +274,7 @@ Format Page Numbers Формат номерів сторінок Format Page Numbers… Формат номерів сторінок… Format Painter Пензель форматування + Formatted Text Форматований текст Formatting at Selection Форматування виділення Formula Формула General Загальні @@ -304,6 +306,7 @@ Home Основне Horizontal Горизонтальна Horizontal Line Горизонтальна лінія + HTML Format Формат HTML Hyphenation Перенесення слів Icons Піктограми Ignore All Ігнорувати всі @@ -355,6 +358,7 @@ Keep Text Only Зберегти лише текст Keep with Next Не відривати від наступного Keep with next Не відривати від наступного + Keeps the fonts and formatting of the copied text. Зберігає шрифти й форматування скопійованого тексту. Labels Етикетки Landscape Альбомна Language Мова @@ -496,7 +500,10 @@ Paragraphs Абзаци Paste Вставити Paste (⌘V) Вставити (⌘V) + Paste as: Вставити як: Paste Formatting Вставити форматування + Paste Special Спеціальна вставка + Paste Special… Спеціальна вставка… Paste: Keep Text Only Вставлення: зберегти лише текст Paste: Merge Formatting Вставлення: об'єднати форматування PDF document (*.pdf) Документ PDF (*.pdf) @@ -531,6 +538,8 @@ Read Mode Режим читання Read\nAloud Читати\nвголос Read\nMode Режим\nчитання + Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування. + Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування. Reading Highlight Виділення під час читання Recent Останні Record Macro Записати макрос @@ -569,6 +578,7 @@ Restart Each Section Починати в кожному розділі Restrict Editing Обмежити редагування Restrict\nEditing Обмежити\nредагування + Result Результат Results Результати Reverse the order Зворотний порядок Review Рецензування @@ -576,6 +586,7 @@ Reviewing Pane Панель рецензування Reviewing — edits become suggestions Рецензування — зміни стають пропозиціями Rich Text Format (*.rtf) Форматований текст (*.rtf) + Rich Text Format (RTF) Формат RTF (Rich Text Format) Right Праворуч Right Border Права межа Right-to-Left Text Direction Напрямок тексту справа наліво @@ -713,6 +724,7 @@ Text: Текст: Text\nBox Текстове\nполе Text\nDirection Напрямок\nтексту + The clipboard is empty. Буфер обміну порожній. The new style takes the formatting of the current paragraph. Новий стиль використовує форматування поточного абзацу. Theme Colors Кольори теми Theme Fonts Шрифти теми @@ -739,6 +751,7 @@ Underline Words Only Підкреслити лише слова Undo Скасувати Undo {action} Скасувати {action} + Unformatted Text Неформатований текст Up Угору Update Field Оновити поле Update Index Оновити покажчик diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7faf9d4a5..4faefa189 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,6 +942,19 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + Paste Special 选择性粘贴 + Paste Special… 选择性粘贴… + Paste as: 粘贴为: + The clipboard is empty. 剪贴板为空。 + Formatted Text 带格式文本 + Rich Text Format (RTF) RTF 格式 + HTML Format HTML 格式 + Unformatted Text 无格式文本 + Result 结果 + Keeps the fonts and formatting of the copied text. 保留所复制文本的字体和格式。 + Reads the clipboard's rich text and keeps its formatting. 读取剪贴板中的 RTF 内容并保留其格式。 + Reads the clipboard's web content and keeps its formatting. 读取剪贴板中的网页内容并保留其格式。 + Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。 Advanced Find 高级查找 Advanced Find… 高级查找… Go To… 定位… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da88b5f04..e36b59080 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,6 +932,19 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + Paste Special 選擇性貼上 + Paste Special… 選擇性貼上… + Paste as: 貼上為: + The clipboard is empty. 剪貼簿是空的。 + Formatted Text 格式化文字 + Rich Text Format (RTF) RTF 格式 + HTML Format HTML 格式 + Unformatted Text 純文字 + Result 結果 + Keeps the fonts and formatting of the copied text. 保留所複製文字的字型和格式。 + Reads the clipboard's rich text and keeps its formatting. 讀取剪貼簿中的 RTF 內容並保留其格式。 + Reads the clipboard's web content and keeps its formatting. 讀取剪貼簿中的網頁內容並保留其格式。 + Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。 Advanced Find 進階尋找 Advanced Find… 進階尋找… Go To… 移至… diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 476801f94..13e2454f0 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -185,7 +185,13 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } } egui::Event::Paste(t) => { - let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" }; + // Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special. + let m = ctx.input(|i| i.modifiers); + let id = match (m.alt, m.shift) { + (true, true) => "edit.pasteText", + (true, false) => "edit.pasteSpecial", + _ => "edit.paste", + }; let _ = app.run(id, json!({"text": t})); } egui::Event::Copy | egui::Event::Cut => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fef649e4..b17245d02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -476,6 +476,7 @@ impl WordApp { self.ui.backstage = true; self.ui.backstage_page = "options".into(); } + "pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)), other => self.dialog = dialogs::Dialog::open(other, self), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c99b9a4f0..73c49093f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -246,6 +246,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Paste", "edit.paste", json!({})); mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({})); + mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({})); }); stack(ui, |ui| { small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false); diff --git a/docs/parity.md b/docs/parity.md index 279962e8f..a227f82b4 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. -**381 of 430 catalog features have commands (88.6%).** +**382 of 430 catalog features have commands (88.8%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 93 | 101 | +| Home | 94 | 101 | | Insert | 42 | 53 | | Draw | 0 | 11 | | Design | 10 | 10 | @@ -30,7 +30,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Home -- Clipboard › Paste Special (`edit.pasteSpecial`) - Clipboard › Clipboard Pane (`edit.clipboardPane`) - Font › Phonetic Guide (`format.phonetic`) - Font › Enclose Characters (`format.enclose`) From e71c2b6c5bf685d675c1b86cd64153b11a2f2dc7 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 19:46:39 +0200 Subject: [PATCH 10/16] 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 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/inspector.rs | 145 +++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/session.rs | 4 + crates/engine/src/tests.rs | 75 +++++++++ crates/ui-egui/src/i18n/es.tsv | 37 +++++ crates/ui-egui/src/i18n/ja.tsv | 37 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 37 +++++ crates/ui-egui/src/i18n/uk.tsv | 37 +++++ crates/ui-egui/src/i18n/zh-hans.tsv | 37 +++++ crates/ui-egui/src/i18n/zh-hant.tsv | 37 +++++ crates/ui-egui/src/panes.rs | 231 +++++++++++++++++++++++++++- docs/parity.md | 5 +- 13 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/inspector.rs 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`) From 15f5ad274e1431953edab7892b1b75fdc69a4c06 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 19:47:06 +0200 Subject: [PATCH 11/16] Column Selection: Alt+drag selects a block of text (#237) select.column (Mod+Shift+F8) selects the same x range on a run of lines, from two positions, two page points (Alt+drag), or by toggling column mode where the arrow keys extend the block. Copy/Cut give one line per row, Delete/Backspace remove only the block, character formatting and Change Case apply per row, typing/paste replace the block. Any ordinary caret move drops it. F8 keys now reach their shortcuts in the UI. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 6 +- crates/engine/src/catalog.rs | 2 +- crates/engine/src/cmd/column.rs | 232 ++++++++++++++++++++++++++++ crates/engine/src/cmd/format.rs | 36 +++++ crates/engine/src/cmd/mod.rs | 4 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/session.rs | 46 +++++- crates/engine/src/tests.rs | 110 +++++++++++++ crates/layout/src/hit.rs | 34 ++++ crates/layout/src/tests.rs | 14 ++ crates/ui-egui/src/canvas.rs | 49 ++++-- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/keys.rs | 1 + crates/ui-egui/src/keytips.rs | 72 +++++++-- crates/ui-egui/src/lib.rs | 5 + docs/parity.md | 8 +- 21 files changed, 592 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/cmd/column.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9c0a9416f..b446e1793 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) | **430** | -| Feature catalog coverage (Word ribbon/menu features with a command) | **383 / 430 (89%)** | +| Commands (every action, scriptable by CLI/MCP/control channel) | **431** | +| Feature catalog coverage (Word ribbon/menu features with a command) | **384 / 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) | @@ -47,7 +47,7 @@ the icon art). | Area | Status | Parity | |---|---|---| -| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat | 85% | +| Typing, selection, clipboard, undo, find/replace | Solid; IME, autocorrect, smart quotes, list autoformat, column (block) selection | 85% | | Character & paragraph formatting | Nearly all properties; Font/Paragraph dialogs | 85% | | Styles (gallery, pane, create/modify, style sets, themes) | Good | 75% | | Lists (bullets, numbering, multilevel, restart, set value) | Good | 75% | diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 687790a3f..02e3567de 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -502,6 +502,6 @@ mod tests { let p = parity(®); let pct = p["percent"].as_f64().unwrap(); // The floor only ever rises. - assert!(pct >= 87.5, "parity {pct}%"); + assert!(pct >= 89.0, "parity {pct}%"); } } diff --git a/crates/engine/src/cmd/column.rs b/crates/engine/src/cmd/column.rs new file mode 100644 index 000000000..82f9a5de6 --- /dev/null +++ b/crates/engine/src/cmd/column.rs @@ -0,0 +1,232 @@ +//! Column (block) selection: Alt+drag, or Ctrl+Shift+F8 then the arrow keys, selects the same +//! horizontal range on a run of lines. Copy and Cut take one line per block row, Delete / +//! Backspace remove just the block, character formatting applies to each row's piece, paragraph +//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other +//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it. + +use serde_json::{Value, json}; +use wordcraft_doc::edit::Fragment; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Block, Paragraph, Pos}; + +use super::{parse_pos, pos_json}; +use crate::{CmdError, CmdResult, ColumnBlock, CommandSpec, Selection, Session, p}; + +/// Most lines a block covers (hostile params: a block over a huge document). +const MAX_LINES: usize = 100_000; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("select.column", "Column Selection", "Editing › Selection", select_column) + .key("Mod+Shift+F8") + .params( + r#"{"anchor": Pos, "focus": Pos} | {"from": {"page": n, "x": pt, "y": pt}, "to": {"page": n, "x": pt, "y": pt}, "story"?: StoryRef} | {"on"?: bool} (no corners: toggle column selection mode, where the arrow keys extend the block)"#, + ) + .pure(), + ] +} + +/// Result: the corners, each block row's text and whether column mode is on. +fn result(s: &Session) -> CmdResult { + let rows: Vec = s.column_segments().map(|segs| segs.iter().map(|(a, b)| row_text(s, a, b)).collect()).unwrap_or_default(); + Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "rows": rows, "columnMode": s.column_mode})) +} + +fn row_text(s: &Session, a: &Pos, b: &Pos) -> String { + let (a, b) = (s.doc.clamp(a), s.doc.clamp(b)); + if a.path != b.path || a.story != b.story { + return String::new(); + } + s.doc.para_at(&a).and_then(|p| p.text.get(a.off.min(b.off)..b.off.max(a.off))).map(str::to_string).unwrap_or_default() +} + +/// `{"page": n, "x": pt, "y": pt}`. +fn point(v: &Value, name: &str) -> Result<(usize, f32, f32), CmdError> { + match (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) { + (Some(pg), Some(x), Some(y)) => Ok((usize::try_from(pg).unwrap_or(usize::MAX), x, y)), + _ => Err(CmdError::Params(format!("`{name}` needs `page`, `x` and `y`"))), + } +} + +fn select_column(s: &mut Session, v: &Value) -> CmdResult { + if let (Some(av), Some(fv)) = (v.get("anchor"), v.get("focus")) { + let a = parse_pos(av).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?; + let b = parse_pos(fv).ok_or_else(|| CmdError::Params("bad `focus`".into()))?; + let (a, b) = (s.doc.clamp(&a), s.doc.clamp(&b)); + if a.story != b.story { + return Err(CmdError::Params("`anchor` and `focus` must be in the same story".into())); + } + let l = s.layout(); + let xa = l.caret_on(&a, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`anchor` isn't laid out".into()))?; + let xb = l.caret_on(&b, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`focus` isn't laid out".into()))?; + make(s, a, b, xa, xb); + return result(s); + } + if let (Some(fv), Some(tv)) = (v.get("from"), v.get("to")) { + let (fp, fx, fy) = point(fv, "from")?; + let (tp, tx, ty) = point(tv, "to")?; + let story = super::story_param(s, v); + let l = s.layout(); + let a = l.hit(fp, fx, fy, story).ok_or_else(|| CmdError::Params("no text at `from`".into()))?; + let b = l.hit(tp, tx, ty, story).ok_or_else(|| CmdError::Params("no text at `to`".into()))?; + s.page_hint = tp; + make(s, a, b, fx, tx); + return result(s); + } + // Ctrl+Shift+F8: toggle the mode; the block starts at the caret. + let on = p::bool(v, "on").unwrap_or(!s.column_mode); + s.column_mode = on; + if on { + let f = s.sel.focus.clone(); + let x = s.layout().caret_on(&f, s.page_hint).map(|c| c.x).unwrap_or(0.0); + make(s, f.clone(), f, x, x); + s.status = "Column selection: the arrow keys extend the block, Esc ends it".into(); + } else { + s.status.clear(); + } + result(s) +} + +/// Make the block between corners `a` and `b` whose page x are `xa` and `xb`. +fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) { + let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 }); + let (left, right) = (xa.min(xb), xa.max(xb)); + let l = s.layout(); + let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES); + s.sel = Selection { anchor: a, focus: b }; + s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments }); + s.pending = None; +} + +/// Column mode: the caret moved to `to`; grow the block to it (keeping the goal column on +/// Up/Down). +fn extend_to(s: &mut Session, to: Pos) { + let Some(block) = s.column.clone() else { return }; + let x = match s.goal_x { + Some(gx) => gx, + None => s.layout().caret_on(&to, s.page_hint).map(|c| c.x).unwrap_or(block.focus_x), + }; + make(s, block.sel.anchor, to, block.anchor_x, x); +} + +/// Run a command with the column block in mind (called by `Session::run` for every command). +pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Session, &Value) -> CmdResult, v: &Value) -> CmdResult { + // A block the selection has moved away from is gone. + if s.column.as_ref().is_some_and(|c| c.sel != s.sel) { + s.column = None; + s.column_mode = false; + } + if s.column_mode && id.starts_with("caret.") && id != "caret.set" { + let mut params = v.clone(); + match params.as_object_mut() { + Some(o) => { + o.insert("extend".into(), Value::Bool(true)); + } + None => params = json!({"extend": true}), + } + run(s, ¶ms)?; + let to = s.sel.focus.clone(); + extend_to(s, to); + return result(s); + } + if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) { + s.column_mode = false; + } + let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) }; + match id { + "edit.copy" => copy(s, &segs), + "edit.cut" => { + let r = copy(s, &segs)?; + delete(s, &segs)?; + Ok(r) + } + "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => { + delete(s, &segs)?; + super::sel_result(s) + } + "format.changeCase" | "format.clear" => per_row(s, run, v, &segs), + _ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v), + _ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => { + delete(s, &segs)?; + run(s, v) + } + _ if mutates => { + let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone()); + s.sel = Selection::caret(at); + s.column = None; + run(s, v) + } + _ => run(s, v), + } +} + +/// Copy: one paragraph per block row (formatting kept); the plain text joins rows with newlines. +fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult { + let mut blocks = Vec::new(); + let mut parts = std::collections::BTreeMap::new(); + let mut rows = Vec::new(); + for (a, b) in segs { + let (a, b) = (s.doc.clamp(a), s.doc.clamp(b)); + let frag = if a.path == b.path && a.story == b.story && a != b { s.doc.copy_range(&a, &b) } else { Fragment::default() }; + // Text boxes in a row keep their stories (keyed by this document's part ids). + parts.extend(frag.parts); + let mut para = frag + .blocks + .into_iter() + .find_map(|bl| match bl { + Block::Para(p) => Some(p), + Block::Table(_) => None, + }) + .unwrap_or_else(|| Paragraph::with_text("", CharProps::default())); + para.section = None; + rows.push(row_text(s, &a, &b)); + blocks.push(Block::Para(para)); + } + s.clipboard_text = rows.join("\n"); + s.clipboard = Some(Fragment { blocks, parts }); + Ok(json!({"text": s.clipboard_text})) +} + +/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at +/// the block's top-left corner. +fn delete(s: &mut Session, segs: &[(Pos, Pos)]) -> Result<(), CmdError> { + for (a, b) in segs.iter().rev() { + let (a, b) = (s.doc.clamp(a), s.doc.clamp(b)); + if a == b || a.path != b.path || a.story != b.story { + continue; + } + if s.doc.settings.track_changes { + super::track_delete(s, &a, &b)?; + } else { + s.doc.delete_range(&a, &b)?; + } + } + let at = segs.first().map(|(a, _)| s.doc.clamp(a)).unwrap_or_else(|| s.sel.focus.clone()); + s.sel = Selection::caret(at); + s.column = None; + s.column_mode = false; + s.goal_x = None; + s.pending = None; + Ok(()) +} + +/// Run a selection command once per non-empty row, then restore the block. +fn per_row(s: &mut Session, run: fn(&mut Session, &Value) -> CmdResult, v: &Value, segs: &[(Pos, Pos)]) -> CmdResult { + let block = s.column.take(); + for (a, b) in segs.iter().rev() { + let (a, b) = (s.doc.clamp(a), s.doc.clamp(b)); + if a == b || a.path != b.path { + continue; + } + s.sel = Selection { anchor: a, focus: b }; + if let Err(e) = run(s, v) { + s.column = block; + return Err(e); + } + } + if let Some(c) = &block { + s.sel = c.sel.clone(); + } + s.column = block; + result(s) +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 0c373c2cf..8e35d2ca1 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -160,8 +160,28 @@ fn target(s: &Session) -> Option<(Pos, Pos)> { None } +/// The non-empty rows of a column selection, if one is active. +fn column_rows(s: &Session) -> Option> { + let segs = s.column_segments()?; + let rows: Vec<(Pos, Pos)> = segs + .iter() + .map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b))) + .filter(|(a, b)| a != b && a.path == b.path && a.story == b.story) + .map(|(a, b)| if a <= b { (a, b) } else { (b, a) }) + .collect(); + // An empty block (column mode just started) formats like a caret. + (!rows.is_empty()).then_some(rows) +} + /// Apply a change to the selection (or the pending caret formatting). pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { + // A column selection formats each row's piece. + if let Some(rows) = column_rows(s) { + for (a, b) in rows { + s.doc.format_range(&a, &b, f)?; + } + return sel_result(s); + } match target(s) { Some((a, b)) => { s.doc.format_range(&a, &b, f)?; @@ -186,6 +206,22 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult { /// Resolved formatting of every character in the target (or the caret). fn resolved(s: &Session) -> Vec { let mut out = Vec::new(); + if let Some(rows) = column_rows(s) { + for (a, b) in rows { + let Some(p) = s.doc.para_at(&a) else { continue }; + for (r, c) in p.run_ranges() { + if r.end > a.off && r.start < b.off { + out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c)); + } + } + if out.len() > 10_000 { + break; + } + } + if !out.is_empty() { + return out; + } + } let Some((a, b)) = target(s) else { let f = &s.sel.focus; let style = s.doc.para_at(f).and_then(|p| p.props.style.clone()); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8ce289867..c8b41d97d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod caret; pub mod citations; +pub mod column; pub mod design; pub mod edit; pub mod equation; @@ -34,6 +35,7 @@ pub fn registry() -> Registry { let mut v = Vec::new(); v.extend(text::specs()); v.extend(caret::specs()); + v.extend(column::specs()); v.extend(edit::specs()); v.extend(paste::specs()); v.extend(format::specs()); @@ -122,7 +124,7 @@ pub fn join_next_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Pa } /// Tracked deletion: own insertions are removed, other text is marked deleted. -fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result { +pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result { let rid = new_revision(s, RevisionKind::Delete); let author = s.author.clone(); // Remove text this author inserted (it never existed for the reader); mark the rest. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 752f92cb1..4a5f76233 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use serde_json::Value; -pub use session::{EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState}; +pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState}; pub use wordcraft_doc as doc; pub use wordcraft_layout as layout; pub use wordcraft_render as render; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index b32ef2819..318dad034 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -31,6 +31,19 @@ impl Selection { } } +/// A column (block) selection: the same x range on every line between two corners. +#[derive(Clone, Debug, PartialEq)] +pub struct ColumnBlock { + /// The stream selection the block was made with (its corners). The block is current only + /// while `Session::sel` still equals it, so any ordinary caret move or selection drops it. + pub sel: Selection, + /// Page x of the anchor and focus corners (points); the block spans the range between. + pub anchor_x: f32, + pub focus_x: f32, + /// One `(start, end)` per line, in document order, each inside one paragraph. + pub segments: Vec<(Pos, Pos)>, +} + /// View state that commands can change (ribbon View tab, status bar). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -224,6 +237,10 @@ pub struct Session { pub math_latex: bool, /// Text typed into equations is normal (non-math) text. pub math_normal_text: bool, + /// Column (block) selection, if one was made (see [`Session::column_segments`]). + pub column: Option, + /// Column selection mode (Ctrl+Shift+F8): caret movement extends the block. + pub column_mode: bool, } /// The equation being edited. @@ -297,9 +314,17 @@ impl Session { math_normal_text: false, prefs: Prefs::default(), read_aloud: Default::default(), + column: None, + column_mode: false, } } + /// The line pieces of the current column selection, if there is one (the selection hasn't + /// moved since the block was made). + pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> { + self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice()) + } + /// Document revision (bumped by every change). pub fn rev(&self) -> u64 { self.rev @@ -441,6 +466,8 @@ impl Session { self.doc.ensure_nonempty(); self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); self.pending = None; + self.column = None; + self.column_mode = false; self.reset_history(); self.touch(); self.dirty = false; @@ -507,6 +534,9 @@ impl Session { math: _, math_latex: _, math_normal_text: _, + // Column selection, like `sel`'s shape: valid only while `sel` matches it. + column: _, + column_mode: _, } = self; let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() }; EditSnapshot { @@ -607,7 +637,15 @@ impl Session { // The undo stacks are snapshotted whole, not as a length: commands run nested commands, // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. let before_doc = if spec.mutates { - Some((self.doc.clone(), self.sel.clone(), self.history.clone(), self.redo.clone(), self.typing_open, self.undo_evicted)) + Some(( + self.doc.clone(), + self.sel.clone(), + self.history.clone(), + self.redo.clone(), + self.typing_open, + self.undo_evicted, + self.column.clone(), + )) } else { None }; @@ -626,7 +664,8 @@ impl Session { self.typing_open = false; } let run = spec.run; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params))); + let mutates = spec.mutates; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params))); let result = match result { Ok(r) => r, Err(p) => { @@ -644,7 +683,7 @@ impl Session { self.clamp_selection(); } Err(e) => { - if let Some((d, s, h, r, t, ev)) = before_doc { + if let Some((d, s, h, r, t, ev, c)) = before_doc { self.doc = d; self.sel = s; self.history = h; @@ -652,6 +691,7 @@ impl Session { self.typing_open = t; // The steps the command pushed out are back, so they no longer count as gone. self.undo_evicted = ev; + self.column = c; } self.status = e.to_string(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 65bbc83c3..620cf3998 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1779,6 +1779,116 @@ fn timestamps_come_from_the_clock() { assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } +fn column_doc() -> Session { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"})); + s +} + +fn block(s: &mut Session) { + let a = serde_json::to_value(Pos::body(0, 2)).unwrap(); + let f = serde_json::to_value(Pos::body(2, 4)).unwrap(); + let r = run(s, "select.column", json!({"anchor": a, "focus": f})); + assert_eq!(r["rows"], json!(["cd", "cd", "cd"])); +} + +#[test] +fn column_selection_copies_the_block() { + let mut s = column_doc(); + block(&mut s); + let r = run(&mut s, "edit.copy", json!({})); + assert_eq!(r["text"], "cd\ncd\ncd"); + assert_eq!(s.clipboard.as_ref().map(|f| f.blocks.len()), Some(3)); + // A caret move drops the block; pasting the copy gives three paragraphs. + run(&mut s, "caret.docEnd", json!({})); + assert!(s.column_segments().is_none()); + s.autocorrect_on = false; + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd"); +} + +#[test] +fn column_selection_deletes_only_the_block() { + let mut s = column_doc(); + block(&mut s); + run(&mut s, "text.delete", json!({})); + assert_eq!(text(&s), "abef\nabef\nabef"); + assert_eq!(s.sel, crate::Selection::caret(Pos::body(0, 2))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "abcdef\nabcdef\nabcdef"); + // Cut copies and deletes; typing replaces the block. + block(&mut s); + let r = run(&mut s, "edit.cut", json!({})); + assert_eq!(r["text"], "cd\ncd\ncd"); + assert_eq!(text(&s), "abef\nabef\nabef"); + run(&mut s, "edit.undo", json!({})); + block(&mut s); + run(&mut s, "text.insert", json!({"text": "X"})); + assert_eq!(text(&s), "abXef\nabef\nabef"); +} + +#[test] +fn column_selection_formats_each_row() { + let mut s = column_doc(); + block(&mut s); + run(&mut s, "format.bold", json!({})); + for i in 0..3 { + let p = s.doc.para_at(&Pos::body(i, 0)).unwrap(); + assert_ne!(p.props_of_char(1).bold, Some(true)); + assert_eq!(p.props_of_char(2).bold, Some(true)); + assert_eq!(p.props_of_char(3).bold, Some(true)); + assert_ne!(p.props_of_char(4).bold, Some(true)); + } + // The block survives formatting, and a second Bold turns it off again. + assert!(s.column_segments().is_some()); + run(&mut s, "format.bold", json!({})); + assert_ne!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props_of_char(2).bold, Some(true)); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(text(&s), "abCDef\nabCDef\nabCDef"); +} + +#[test] +fn column_mode_extends_with_the_arrow_keys() { + let mut s = column_doc(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}})); + let r = run(&mut s, "select.column", json!({})); + assert_eq!(r["columnMode"], true); + run(&mut s, "caret.right", json!({})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "caret.down", json!({})); + let r = run(&mut s, "caret.down", json!({})); + assert_eq!(r["rows"], json!(["bc", "bc", "bc"])); + run(&mut s, "text.backspace", json!({})); + assert_eq!(text(&s), "adef\nadef\nadef"); + assert!(!s.column_mode); + // Escape leaves the mode. + run(&mut s, "select.column", json!({})); + run(&mut s, "select.collapse", json!({})); + assert!(!s.column_mode); +} + +#[test] +fn column_selection_from_points() { + let mut s = column_doc(); + let l = s.layout(); + let a = l.caret(&Pos::body(0, 1)).unwrap(); + let b = l.caret(&Pos::body(1, 5)).unwrap(); + let r = run( + &mut s, + "select.column", + json!({"from": {"page": a.page, "x": a.x, "y": a.top + 2.0}, "to": {"page": b.page, "x": b.x, "y": b.top + 2.0}}), + ); + assert_eq!(r["rows"], json!(["bcde", "bcde"])); + for bad in [ + json!({"from": {"page": 99, "x": 1, "y": 1}, "to": {"page": 0, "x": 1, "y": 1}}), + json!({"from": 1, "to": "x"}), + json!({"anchor": 1, "focus": {}}), + ] { + assert!(s.run("select.column", &bad).is_err()); + } +} + #[test] fn style_inspector_reports_and_clears_levels() { let mut s = s(); diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 8cc2bbfa1..94362abef 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -373,6 +373,40 @@ impl DocLayout { None } + /// Column (block) selection: the piece of every line from the line holding `a` to the line + /// holding `b` (document order) that lies between page x `left` and `right`. One `(start, + /// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty + /// pair. At most `max` lines. + pub fn column_segments(&self, doc: &Document, a: &Pos, b: &Pos, left: f32, right: f32, page_hint: usize, max: usize) -> Vec<(Pos, Pos)> { + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + let (left, right) = if left <= right { (left, right) } else { (right, left) }; + let mut out = Vec::new(); + if a.story != b.story || !left.is_finite() || !right.is_finite() { + return out; + } + for path in doc.paths_between(a, b) { + for (_, it) in self.pieces(a.story, &path, page_hint) { + let Placed::Lines { para, l0, l1, x, .. } = it else { continue }; + let first = if path == a.path { para.line_of(a.off) } else { 0 }; + let last = if path == b.path { para.line_of(b.off) } else { usize::MAX }; + for li in *l0..*l1 { + if li < first || li > last { + continue; + } + if out.len() >= max { + return out; + } + let o0 = para.off_at_x(li, left - x); + let o1 = para.off_at_x(li, right - x); + let (o0, o1) = if o0 <= o1 { (o0, o1) } else { (o1, o0) }; + let mk = |off| Pos { story: a.story, path: path.clone(), off }; + out.push((mk(o0), mk(o1))); + } + } + } + out + } + /// Highlight rectangles for the selection `a..b`, per page. pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> { let (a, b) = if a <= b { (a, b) } else { (b, a) }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 601fb80f5..c84f1182a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -656,6 +656,20 @@ fn selection_rects_cover_range() { assert!(r[0].1.w > 20.0); } +#[test] +fn column_segments_take_the_same_x_range_on_every_line() { + let d = Document::from_text("abcdef\nabcdef\nab"); + let l = lay(&d); + let left = l.caret(&Pos::body(0, 2)).unwrap().x; + let right = l.caret(&Pos::body(0, 4)).unwrap().x; + let segs = l.column_segments(&d, &Pos::body(2, 2), &Pos::body(0, 2), right, left, 0, 100); + let offs: Vec<(u32, usize, usize)> = segs.iter().map(|(a, b)| (a.path.0[0], a.off, b.off)).collect(); + assert_eq!(offs, vec![(0, 2, 4), (1, 2, 4), (2, 2, 2)]); + // Capped, and junk x gives nothing. + assert_eq!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), left, right, 0, 2).len(), 2); + assert!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), f32::NAN, right, 0, 100).is_empty()); +} + #[test] fn display_has_glyphs_and_marks() { let mut d = Document::from_text("Hello\tworld"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c87d4000..f1027810b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -23,6 +23,8 @@ pub struct CanvasState { pub scroll_to_caret: bool, pub caret_visible_since: f64, dragging: bool, + /// Alt+drag: where the column (block) selection started (page, x, y) and its last end. + column_drag: Option<((usize, f32, f32), (usize, f32, f32))>, pub last_highlight: String, pub last_font_color: String, pub last_shading: String, @@ -67,6 +69,7 @@ impl Default for CanvasState { scroll_to_caret: true, caret_visible_since: 0.0, dragging: false, + column_drag: None, last_highlight: "yellow".into(), last_font_color: "C00000".into(), last_shading: "FFF2CC".into(), @@ -493,16 +496,22 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // 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(); + // A column selection highlights each row's piece. + let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() { + Some(segs) => segs.to_vec(), + None => vec![app.session.sel.ordered()], + }; let mut first: Option = None; - 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 sr = 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(sr, 0.0, t.selection); - // Track the topmost rect: the mini toolbar anchors on the selection's first line. - if first.is_none_or(|f| sr.min.y < f.min.y) { - first = Some(sr); + for (a, b) in &pieces { + 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 sr = 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(sr, 0.0, t.selection); + // Track the topmost rect: the mini toolbar anchors on the selection's first line. + if first.is_none_or(|f| sr.min.y < f.min.y) { + first = Some(sr); + } } } } @@ -864,6 +873,15 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo return; } let pj = serde_json::to_value(&pos).unwrap_or_default(); + // Alt+drag selects a column (block) of text. + if mods.alt && !mods.shift && !mods.command { + let _ = app.run("caret.set", json!({"pos": pj})); + app.session.page_hint = page; + app.canvas.column_drag = Some(((page, x, y), (page, x, y))); + app.canvas.dragging = true; + return; + } + app.canvas.column_drag = None; let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift})); if mods.command && !mods.shift { let _ = app.run("select.sentence", json!({})); @@ -871,6 +889,18 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo app.session.page_hint = page; app.canvas.dragging = true; // Format painter applies on mouse up. + } else if app.canvas.dragging + && ui.input(|i| i.pointer.primary_down()) + && let Some((from, last)) = app.canvas.column_drag + { + if last != (page, x, y) { + app.canvas.column_drag = Some((from, (page, x, y))); + let story = app.session.sel.focus.story; + let _ = app.run( + "select.column", + json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}), + ); + } } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) { if let Some(pos) = layout.hit(page, x, y, story) && pos != app.session.sel.focus @@ -882,6 +912,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo // Auto-scroll near the edges is handled by egui's scroll area drag. } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) { app.canvas.dragging = false; + app.canvas.column_drag = None; if app.session.painter.is_some() && !app.session.sel.is_collapsed() { let _ = app.run("edit.pasteFormat", json!({})); } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7f453537b..cef6540f0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -975,6 +975,7 @@ Dark Oscuro Use system setting Usar la configuración del sistema A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto. + Column Selection Selección de columna New Table Style Nuevo estilo de tabla Modify Table Style Modificar estilo de tabla New Table Style… Nuevo estilo de tabla… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6b9331477..7c6967c2a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,6 +931,7 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + Column Selection 矩形選択 Style Inspector スタイル インスペクター Inspect Styles at Selection 選択箇所のスタイルを調べる Clear Formatting Level 書式のレベルをクリア diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d36694ffd..32c659701 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,6 +943,7 @@ 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. + Column Selection Seleção de coluna Style Inspector Inspetor de estilos Inspect Styles at Selection Inspecionar estilos na seleção Clear Formatting Level Limpar nível de formatação diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5f3b53419..f20d00d2c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -970,6 +970,7 @@ Dark Темна Use system setting Як у системі A file dialog is already open. Діалогове вікно файлу вже відкрито. + Column Selection Виділення стовпця New Table Style Новий стиль таблиці Modify Table Style Змінити стиль таблиці New Table Style… Новий стиль таблиці… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cfea6a41a..2d1b24a94 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,6 +942,7 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + Column Selection 矩形选择 Style Inspector 样式检查器 Inspect Styles at Selection 检查所选内容的样式 Clear Formatting Level 清除格式级别 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 72a7db5ff..304acb69b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,6 +932,7 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + Column Selection 矩形選取 Style Inspector 樣式檢查器 Inspect Styles at Selection 檢查選取範圍的樣式 Clear Formatting Level 清除格式層級 diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 13e2454f0..ea293c669 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -38,6 +38,7 @@ pub fn key_name(k: Key) -> Option<&'static str> { Key::F3 => "F3", Key::F5 => "F5", Key::F7 => "F7", + Key::F8 => "F8", Key::F9 => "F9", Key::F12 => "F12", Key::A => "A", diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index f9cc4731e..232419fe5 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -590,20 +590,36 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) { let mut alt_released = false; let mut f10 = false; for e in &events { - if let egui::Event::Key { key, pressed, modifiers, .. } = e { - if is_alt(*key) { - if *pressed && modifiers.any() { - // Alt combined with another modifier is a shortcut, not a keytip chord. + match e { + egui::Event::Key { key, pressed, repeat, modifiers, .. } => { + if is_alt(*key) { + if *pressed { + // A new Alt press starts a fresh chord (auto-repeat keeps the current one). + if !*repeat { + app.ui.alt_chord_used = false; + } + if modifiers.any() { + // Alt combined with another modifier is a shortcut, not a keytip chord. + other_key = true; + } + } else if !modifiers.any() { + // Alt+click and Alt+drag (column selection), or Alt+key held across frames, + // end with a bare Alt release too; only an untouched Alt tap counts. + alt_released = !std::mem::take(&mut app.ui.alt_chord_used); + } + } else if *pressed { other_key = true; - } else if !*pressed && !modifiers.any() { - alt_released = true; - } - } else if *pressed { - other_key = true; - if *key == egui::Key::F10 && !modifiers.any() { - f10 = true; + app.ui.alt_chord_used = true; + if *key == egui::Key::F10 && !modifiers.any() { + f10 = true; + } } } + egui::Event::PointerButton { pressed: true, .. } => { + other_key = true; + app.ui.alt_chord_used = true; + } + _ => {} } } // Route letter/navigation keys to the keytips while they are showing. @@ -739,6 +755,40 @@ mod tests { } } + /// Alt+drag selects a column (#237); releasing Alt afterwards must not open keytips. A bare + /// Alt tap still does (off macOS, where Option types accents). + #[test] + fn alt_click_or_drag_does_not_arm_keytips_on_release() { + let ctx = egui::Context::default(); + let mut a = app(); + a.ui.backstage = false; + a.dialog = None; + let alt = egui::Modifiers { alt: true, ..Default::default() }; + let key = |pressed, modifiers| egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed, repeat: false, modifiers }; + let pos = egui::pos2(400.0, 300.0); + let frame = |a: &mut WordApp, events: Vec| { + let input = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(input, |ui| logic(a, ui.ctx())).drop_without_applying_deltas(); + }; + // Alt down, drag with the mouse over several frames, Alt up. + frame(&mut a, vec![key(true, alt)]); + frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }]); + frame(&mut a, vec![egui::Event::PointerMoved(pos + egui::vec2(60.0, 40.0))]); + frame(&mut a, vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }]); + frame(&mut a, vec![key(false, egui::Modifiers::NONE)]); + assert_eq!(a.ui.keytips, Phase::Off, "Alt+drag then Alt up leaves keytips off"); + // Alt held while a key is pressed in a later frame: also a chord. + frame(&mut a, vec![key(true, alt)]); + frame(&mut a, vec![egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: alt }]); + frame(&mut a, vec![key(false, egui::Modifiers::NONE)]); + assert_eq!(a.ui.keytips, Phase::Off, "Alt+key then Alt up leaves keytips off"); + // A bare tap (press and release in separate frames) arms them. + frame(&mut a, vec![key(true, alt)]); + frame(&mut a, vec![key(false, egui::Modifiers::NONE)]); + let tap = if cfg!(target_os = "macos") { Phase::Off } else { Phase::Tabs }; + assert_eq!(a.ui.keytips, tap); + } + #[test] fn keytip_state_machine() { let mut a = app(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b17245d02..fb9220da6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -105,6 +105,10 @@ pub struct UiState { /// Keytips (Alt) state; not persisted, it resets each session. #[serde(skip)] pub keytips: crate::keytips::Phase, + /// Since the last Alt press, another key or a mouse button was pressed: the Alt release + /// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap. + #[serde(skip)] + pub alt_chord_used: bool, /// View › Switch Modes: show pages dark (white text on black), kept between runs. pub dark_page: bool, } @@ -128,6 +132,7 @@ impl Default for UiState { read_aloud_rate: 1.0, read_aloud_skip_citations: true, keytips: crate::keytips::Phase::Off, + alt_chord_used: false, dark_page: false, } } diff --git a/docs/parity.md b/docs/parity.md index d72f8c35c..88118abdb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ 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. -**383 of 430 catalog features have commands (89.1%).** +**384 of 430 catalog features have commands (89.3%).** | Tab | Live | Total | |---|---|---| @@ -23,7 +23,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Picture Format | 13 | 13 | | Shape Format | 2 | 6 | | Header & Footer | 5 | 5 | -| Editing | 23 | 24 | +| Editing | 24 | 24 | | Tools | 3 | 6 | ## Missing @@ -98,10 +98,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Text › Align Text (`shape.alignText`) - Text › Create Link (`shape.link`) -### Editing - -- Selection › Column Selection (`select.column`) - ### Tools - Customize › Customize Ribbon (`tools.customizeRibbon`) From 8ac57f0312bf3293ce24afe9e520d3834168e780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Sat, 10 Oct 2026 19:47:38 +0200 Subject: [PATCH 12/16] i18n: add Serbian, both scripts (sr, sr-latn) (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serbian is officially digraphic — Cyrillic and Latin are both in everyday use, with Cyrillic the default per BCP 47 when no script is given. Adds both as separate catalogs, same as the existing Simplified/Traditional Chinese split handles script rather than region: - sr-latn.tsv: the actual translation, written clean-room from the plain meaning of each English label (never from Microsoft Word's or another product's Serbian localisation), covering the full existing catalog (874 entries, same key set as zh-hans.tsv). - sr.tsv: mechanically transliterated from sr-latn.tsv by a small script (not committed — a one-off; the mapping is the standard digraph-aware Serbian Latin→Cyrillic table). Brand/product/format names (WordCraft, PDF, Discord, Zotero, SmartArt, file extensions…) and `{placeholders}` are protected from transliteration, same names that stay in Latin letters in every other catalog. Hand-editing this file isn't the workflow — change sr-latn.tsv and re-transliterate. - mod.rs: registers `sr` (Cyrillic) and `sr-latn`. No special-casing needed in `candidates()` for the script split, unlike Chinese's region-implies-script fallback — `sr-Latn-RS` already produces `sr-latn` as a candidate before falling back to plain `sr`, and a bare `sr`/`sr-RS` (no script tag, the common case) resolves straight to `sr` since that's the registered default-script code. - tests.rs: locale-matching coverage for both scripts, full-catalog parity with the reference language, and bundled-font glyph coverage for both Cyrillic and Serbian Latin's extended letters (š č ć ž đ), mirroring the existing Ukrainian/Portuguese tests. Co-authored-by: Claude Sonnet 5 Co-authored-by: Brandon Thomas --- crates/engine/src/tests.rs | 28 +- crates/ui-egui/src/i18n/es.tsv | 72 +- crates/ui-egui/src/i18n/ja.tsv | 106 +-- crates/ui-egui/src/i18n/mod.rs | 8 +- crates/ui-egui/src/i18n/pt-br.tsv | 106 +-- crates/ui-egui/src/i18n/sr-latn.tsv | 1007 ++++++++++++++++++++++++++ crates/ui-egui/src/i18n/sr.tsv | 1012 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 68 ++ crates/ui-egui/src/i18n/uk.tsv | 72 +- crates/ui-egui/src/i18n/zh-hans.tsv | 106 +-- crates/ui-egui/src/i18n/zh-hant.tsv | 106 +-- 11 files changed, 2392 insertions(+), 299 deletions(-) create mode 100644 crates/ui-egui/src/i18n/sr-latn.tsv create mode 100644 crates/ui-egui/src/i18n/sr.tsv diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 620cf3998..a14372993 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1765,20 +1765,6 @@ fn styles_apply_rejected_on_protected_document() { assert_eq!(s.undo_labels(), undo); } -/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01). -#[test] -fn timestamps_come_from_the_clock() { - let before = cmd::now_unix(); - assert!(before > 1_760_000_000, "{before}"); - let mut s = s(); - run(&mut s, "text.insert", json!({"text": "Dated"})); - run(&mut s, "select.text", json!({"text": "Dated"})); - run(&mut s, "review.newComment", json!({"text": "When?"})); - let date = run(&mut s, "review.comments", json!({}))[0]["date"].as_str().unwrap_or_default().to_string(); - // Fixed-width ISO strings sort by time. - assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); -} - fn column_doc() -> Session { let mut s = s(); run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"})); @@ -2129,3 +2115,17 @@ fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() { run(&mut s, "view.zoom100", json!({})); assert_eq!(pct(&s), 100); } + +/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01). +#[test] +fn timestamps_come_from_the_clock() { + let before = cmd::now_unix(); + assert!(before > 1_760_000_000, "{before}"); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Dated"})); + run(&mut s, "select.text", json!({"text": "Dated"})); + run(&mut s, "review.newComment", json!({"text": "When?"})); + let date = run(&mut s, "review.comments", json!({}))[0]["date"].as_str().unwrap_or_default().to_string(); + // Fixed-width ISO strings sort by time. + assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cef6540f0..1a40646af 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -922,6 +922,42 @@ Opening print dialog… Abriendo el cuadro de diálogo de impresión… Print failed: {error} Error al imprimir: {error} Couldn't open {path}: {error} No se pudo abrir {path}: {error} + Column Selection Selección de columna + New Table Style Nuevo estilo de tabla + Modify Table Style Modificar estilo de tabla + New Table Style… Nuevo estilo de tabla… + Modify Table Style… Modificar estilo de tabla… + Apply formatting to: Aplicar formato a: + 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 Letters Cartas E-mail Messages Mensajes de correo electrónico Directory Directorio @@ -975,39 +1011,3 @@ Dark Oscuro Use system setting Usar la configuración del sistema A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto. - Column Selection Selección de columna - New Table Style Nuevo estilo de tabla - Modify Table Style Modificar estilo de tabla - New Table Style… Nuevo estilo de tabla… - Modify Table Style… Modificar estilo de tabla… - Apply formatting to: Aplicar formato a: - 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 7c6967c2a..f59246b7f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -878,59 +878,6 @@ Opening print dialog… 印刷ダイアログを開いています… Print failed: {error} 印刷できませんでした: {error} Couldn't open {path}: {error} {path} を開けませんでした: {error} - Letters レター - E-mail Messages 電子メール - Directory ディレクトリ - Normal Word Document 通常の文書 - If…Then…Else… 条件 (If…Then…Else)… - If…Then…Else 条件 (If…Then…Else) - Merge Record # 差し込みレコード番号 - Skip Record If… レコードをスキップする条件… - Skip Record If レコードをスキップする条件 - Equals: 等しい値: - Then insert: 一致したら挿入: - Otherwise insert: 一致しなければ挿入: - Courtesy Title 敬称 - First Name 名 - Last Name 姓 - Company 会社 - Address 住所 - City 市区町村 - State 都道府県 - Postal Code 郵便番号 - Country 国 - (not matched) (未対応) - Leave a recipient out of the merge when a field has this value. フィールドがこの値のとき、その宛先を差し込みから除外します。 - Insert one text or another, depending on a field's value. フィールドの値に応じて、どちらかのテキストを挿入します。 - Select recipients first to match their fields. フィールドを対応付けるには、まず宛先を選択してください。 - The recipient-list field that fills each part of an address block and greeting line. 住所ブロックとあいさつ文の各部分に使われる宛先リストのフィールドです。 - No errors found: every merge field is in the recipient list. エラーは見つかりませんでした。すべての差し込みフィールドが宛先リストにあります。 - These merge fields aren't in the recipient list: 次の差し込みフィールドは宛先リストにありません: - Add at least one field. フィールドを1つ以上追加してください。 - Add Field フィールドの追加 - Every field needs a name. すべてのフィールドに名前が必要です。 - Field name フィールド名 - Field names must be different. フィールド名は重複できません。 - Fields: フィールド: - Field: フィールド: - Find: 検索: - New Entry 新しいエントリ - Recipient List 宛先リスト - Recipients: {count} 宛先: {count} - Remove field フィールドを削除 - Remove recipient 宛先を削除 - Select recipients first to choose from their fields. フィールドから選ぶには、まず宛先を選択してください。 - Type a New List… 新しいリストを入力… - Type at least one recipient. 宛先を1件以上入力してください。 - Type the recipients, or load them from a file. 宛先を入力するか、ファイルから読み込みます。 - Use an Existing List… 既存のリストを使用… - Interface theme: 表示テーマ: - Interface\nTheme 表示テーマ - Interface Theme 表示テーマ - Light ライト - Dark ダーク - Use system setting システム設定を使用 - A file dialog is already open. ファイル ダイアログが既に開いています。 Column Selection 矩形選択 Style Inspector スタイル インスペクター Inspect Styles at Selection 選択箇所のスタイルを調べる @@ -994,3 +941,56 @@ Space before 段落前の間隔 Suppress line numbers 行番号を表示しない Underline color 下線の色 + Letters レター + E-mail Messages 電子メール + Directory ディレクトリ + Normal Word Document 通常の文書 + If…Then…Else… 条件 (If…Then…Else)… + If…Then…Else 条件 (If…Then…Else) + Merge Record # 差し込みレコード番号 + Skip Record If… レコードをスキップする条件… + Skip Record If レコードをスキップする条件 + Equals: 等しい値: + Then insert: 一致したら挿入: + Otherwise insert: 一致しなければ挿入: + Courtesy Title 敬称 + First Name 名 + Last Name 姓 + Company 会社 + Address 住所 + City 市区町村 + State 都道府県 + Postal Code 郵便番号 + Country 国 + (not matched) (未対応) + Leave a recipient out of the merge when a field has this value. フィールドがこの値のとき、その宛先を差し込みから除外します。 + Insert one text or another, depending on a field's value. フィールドの値に応じて、どちらかのテキストを挿入します。 + Select recipients first to match their fields. フィールドを対応付けるには、まず宛先を選択してください。 + The recipient-list field that fills each part of an address block and greeting line. 住所ブロックとあいさつ文の各部分に使われる宛先リストのフィールドです。 + No errors found: every merge field is in the recipient list. エラーは見つかりませんでした。すべての差し込みフィールドが宛先リストにあります。 + These merge fields aren't in the recipient list: 次の差し込みフィールドは宛先リストにありません: + Add at least one field. フィールドを1つ以上追加してください。 + Add Field フィールドの追加 + Every field needs a name. すべてのフィールドに名前が必要です。 + Field name フィールド名 + Field names must be different. フィールド名は重複できません。 + Fields: フィールド: + Field: フィールド: + Find: 検索: + New Entry 新しいエントリ + Recipient List 宛先リスト + Recipients: {count} 宛先: {count} + Remove field フィールドを削除 + Remove recipient 宛先を削除 + Select recipients first to choose from their fields. フィールドから選ぶには、まず宛先を選択してください。 + Type a New List… 新しいリストを入力… + Type at least one recipient. 宛先を1件以上入力してください。 + Type the recipients, or load them from a file. 宛先を入力するか、ファイルから読み込みます。 + Use an Existing List… 既存のリストを使用… + Interface theme: 表示テーマ: + Interface\nTheme 表示テーマ + Interface Theme 表示テーマ + Light ライト + Dark ダーク + Use system setting システム設定を使用 + A file dialog is already open. ファイル ダイアログが既に開いています。 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index a2f840441..e67c38ef6 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -47,7 +47,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 7] = [ +pub static LANGUAGES: [LangInfo; 9] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() }, @@ -60,6 +60,12 @@ pub static LANGUAGES: [LangInfo; 7] = [ // Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no // catalog yet (the European vocabulary differs), so they stay in English. LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + // Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`, + // `sr-Cyrl-*` resolve here. + LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + // Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix + // matching — no special-casing needed, unlike Chinese's script-by-region fallback). + LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c659701..240de90b7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -890,59 +890,6 @@ Opening print dialog… Abrindo a caixa de diálogo de impressão… Print failed: {error} Falha na impressão: {error} Couldn't open {path}: {error} Não foi possível abrir {path}: {error} - Letters Cartas - E-mail Messages Mensagens de email - Directory Diretório - Normal Word Document Documento normal - If…Then…Else… Se…Então…Senão… - If…Then…Else Se…Então…Senão - Merge Record # Nº do registro de mesclagem - Skip Record If… Ignorar registro se… - Skip Record If Ignorar registro se - Equals: É igual a: - Then insert: Então inserir: - Otherwise insert: Senão, inserir: - Courtesy Title Pronome de tratamento - First Name Nome - Last Name Sobrenome - Company Empresa - Address Endereço - City Cidade - State Estado - Postal Code CEP - Country País - (not matched) (sem correspondência) - Leave a recipient out of the merge when a field has this value. Deixar um destinatário fora da mesclagem quando um campo tiver este valor. - Insert one text or another, depending on a field's value. Inserir um texto ou outro, conforme o valor de um campo. - Select recipients first to match their fields. Selecione os destinatários primeiro para corresponder os campos. - The recipient-list field that fills each part of an address block and greeting line. O campo da lista de destinatários que preenche cada parte do bloco de endereço e da linha de saudação. - No errors found: every merge field is in the recipient list. Nenhum erro encontrado: todos os campos de mesclagem estão na lista de destinatários. - These merge fields aren't in the recipient list: Estes campos de mesclagem não estão na lista de destinatários: - Add at least one field. Adicione pelo menos um campo. - Add Field Adicionar campo - Every field needs a name. Todo campo precisa de um nome. - Field name Nome do campo - Field names must be different. Os nomes dos campos devem ser diferentes. - Fields: Campos: - Field: Campo: - Find: Localizar: - New Entry Nova entrada - Recipient List Lista de destinatários - Recipients: {count} Destinatários: {count} - Remove field Remover campo - Remove recipient Remover destinatário - Select recipients first to choose from their fields. Selecione os destinatários primeiro para escolher entre os campos. - Type a New List… Digitar uma nova lista… - Type at least one recipient. Digite pelo menos um destinatário. - Type the recipients, or load them from a file. Digite os destinatários ou carregue-os de um arquivo. - Use an Existing List… Usar uma lista existente… - Interface theme: Tema da interface: - Interface\nTheme Tema da\ninterface - Interface Theme Tema da interface - Light Claro - 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. Column Selection Seleção de coluna Style Inspector Inspetor de estilos Inspect Styles at Selection Inspecionar estilos na seleção @@ -1006,3 +953,56 @@ Space before Espaço antes Suppress line numbers Suprimir números de linha Underline color Cor do sublinhado + Letters Cartas + E-mail Messages Mensagens de email + Directory Diretório + Normal Word Document Documento normal + If…Then…Else… Se…Então…Senão… + If…Then…Else Se…Então…Senão + Merge Record # Nº do registro de mesclagem + Skip Record If… Ignorar registro se… + Skip Record If Ignorar registro se + Equals: É igual a: + Then insert: Então inserir: + Otherwise insert: Senão, inserir: + Courtesy Title Pronome de tratamento + First Name Nome + Last Name Sobrenome + Company Empresa + Address Endereço + City Cidade + State Estado + Postal Code CEP + Country País + (not matched) (sem correspondência) + Leave a recipient out of the merge when a field has this value. Deixar um destinatário fora da mesclagem quando um campo tiver este valor. + Insert one text or another, depending on a field's value. Inserir um texto ou outro, conforme o valor de um campo. + Select recipients first to match their fields. Selecione os destinatários primeiro para corresponder os campos. + The recipient-list field that fills each part of an address block and greeting line. O campo da lista de destinatários que preenche cada parte do bloco de endereço e da linha de saudação. + No errors found: every merge field is in the recipient list. Nenhum erro encontrado: todos os campos de mesclagem estão na lista de destinatários. + These merge fields aren't in the recipient list: Estes campos de mesclagem não estão na lista de destinatários: + Add at least one field. Adicione pelo menos um campo. + Add Field Adicionar campo + Every field needs a name. Todo campo precisa de um nome. + Field name Nome do campo + Field names must be different. Os nomes dos campos devem ser diferentes. + Fields: Campos: + Field: Campo: + Find: Localizar: + New Entry Nova entrada + Recipient List Lista de destinatários + Recipients: {count} Destinatários: {count} + Remove field Remover campo + Remove recipient Remover destinatário + Select recipients first to choose from their fields. Selecione os destinatários primeiro para escolher entre os campos. + Type a New List… Digitar uma nova lista… + Type at least one recipient. Digite pelo menos um destinatário. + Type the recipients, or load them from a file. Digite os destinatários ou carregue-os de um arquivo. + Use an Existing List… Usar uma lista existente… + Interface theme: Tema da interface: + Interface\nTheme Tema da\ninterface + Interface Theme Tema da interface + Light Claro + 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. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv new file mode 100644 index 000000000..5434ef453 --- /dev/null +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -0,0 +1,1007 @@ +# Serbian, Latin script (sr-latn) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# computing vocabulary. No Microsoft Word (or other product) localisation, string table or +# translated UI was consulted or copied; add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. + + (no suggestions) (nema predloga) + 1.5 Line Spacing Proređenost 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Besplatan, otvoreni obrađivač teksta napisan od nule u Rust-u.\nDeo Crafting Apps paketa tima ArtCraft. + About O programu + About WordCraft O WordCraft-u + Accept Prihvati + Accept All Changes Prihvati sve izmene + Accept This Change Prihvati ovu izmenu + Accessibility Pristupačnost + Add Dodaj + Add a comment Dodaj komentar + Add a Table Dodaj tabelu + Add Space Before Paragraph Dodaj razmak pre pasusa + Add Text Dodaj tekst + Add to Dictionary Dodaj u rečnik + Add to Style Gallery Dodaj u galeriju stilova + Address Block Blok adrese + Address: Adresa: + Address\nBlock Blok\nadrese + Adjust Podesi + Advanced Napredno + After: Posle: + Agents Agenti + Align Poravnanje + Align Left Levo poravnanje + Align Right Desno poravnanje + Alignment Poravnanje + All Borders Sve ivice + All Caps Sva velika slova + All caps Sva velika slova + All Markup Sve izmene + Alt Text Alternativni tekst + Apply Character Style Primeni stil znakova + Apply Style Primeni stil + Apply Styles Izaberi i primeni stilove + Arrange Ređanje + Arrange\nAll Rasporedi\nsve + Arrow Strelica + Artistic Effects Umetnički efekti + Author Autor + AutoCorrect Options Opcije automatske ispravke + AutoFit Automatsko uklapanje + AutoFit Contents Automatski uklopi prema sadržaju + AutoFit Window Automatski uklopi prema prozoru + Automatic Automatski + Automatic ({language}) Automatski ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. Automatski prati jezik sistema. Meniji i naredbe se menjaju; dokumenti ne. + Automatic Table 1 (3 levels) Automatska tabela 1 (3 nivoa) + Automatic Table 2 (2 levels) Automatska tabela 2 (2 nivoa) + AutoSave Automatsko čuvanje + AutoSave documents you have saved in WordCraft Automatski čuvaj dokumente koje ste već sačuvali u WordCraft-u + AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko čuvanje neuspelo: {error}. Sačuvajte dokument da biste ga ponovo uključili. + Dark page (white text on black) Tamna stranica (beo tekst na crnom) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikaži dokumente sa invertovanim bojama, kao kod Prikaz › Promeni režim. Čuvanje, štampanje i PDF nisu pogođeni. + AutoSave saves every change to the file (needs a saved document) Automatsko čuvanje upisuje svaku izmenu u fajl (dokument mora biti već sačuvan) + AutoText Automatski tekst + Backspace Backspace + Banded Columns Trakaste kolone + Banded Rows Trakasti redovi + Before: Pre: + Bibliography Bibliografija + Binary assets added Dodati binarni resursi + Binary assets removed Uklonjeni binarni resursi + Blank Prazno + Blank (Three Columns) Prazno (tri kolone) + Blank document Prazan dokument + Blank Page Prazna strana + Block\nAuthors Blokiraj\nautore + Bold Podebljano + Bookmark Obeleživač + Bookmark name: Naziv obeleživača: + Border Painter Kopiranje ivica + Border\nPainter Kopiranje\nivica + Borders Ivice + Bottom Border Donja ivica + Bottom of Page Dno strane + Bottom: Dole: + Box Okvir + Breaks Prekidi + Bring Forward Pomeri napred + Bullet Library Biblioteka oznaka + Bullets Oznake + Calendar Kalendar + Cancel Otkaži + Capitalize Each Word Veliko početno slovo svake reči + Captions Natpisi + Cell Margins Margine ćelije + Cell Size Veličina ćelije + Cell\nMargins Margine\nćelije + Center Centriraj + Change Izmeni + Change Case Promeni veličinu slova + Change List Level Promeni nivo liste + Change Picture Promeni sliku + Change Shape Promeni oblik + Changes Izmene + Character Position Položaj znaka + Character Scale Razmera znaka + Character Shading Senčenje znakova + Character Spacing Razmak između znakova + Character Border Ivica znaka + Characters (no spaces) Znakovi (bez razmaka) + Characters (with spaces) Znakovi (sa razmacima) + Chart Grafikon + Check Accessibility Provera pristupačnosti + Check Compatibility Provera kompatibilnosti + Check for Errors Provera grešaka + Check Spelling as You Type Provera pravopisa dok pišete + Check\nAccessibility Provera\npristupačnosti + Citation Style Stil citiranja + Citations & Bibliography Citati i bibliografija + Clear Obriši + Clear All Formatting Obriši sve formatiranje + Clipboard Ostava + Close Zatvori + Close Header and Footer Zatvori zaglavlje i podnožje + Collapse Selection Skupi izbor + Color Boja + Color: Boja: + Colors Boje + Column Kolona + Column Break Prekid kolone + Columns Kolone + Combine Spoji + Commands Naredbe + Comment Komentar + Comments Komentari + Comments Pane Okno komentara + Community\nDiscord Zajednica\nna Discord-u + Compact Sažeto + Compare Upoređivanje + Compress Pictures Kompresuj slike + Continuous Neprekidno + Convert Pretvori + Convert Text to Table Pretvori tekst u tabelu + Convert Text to Table… Pretvori tekst u tabelu… + Convert to Text Pretvori u tekst + Copy Kopiraj + Copy Formatting Kopiraj formatiranje + Corrections Ispravke + Count words in text boxes and notes Broji reči u okvirima za tekst i fusnotama + Couldn't download {name}: {error} Preuzimanje {name} nije uspelo: {error} + Cover Page Naslovna strana + Create Napravi + Create a Style Napravi stil + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Napravite interaktivni pregled dokumenta.\n\nOdličan je način da pratite gde se nalazite ili brzo premeštate sadržaj.\n\nZa početak, idite na karticu Početak i primenite stilove naslova na naslove u dokumentu. + Create New Style Napravi novi stil + Crop Iseci + Cross-reference Unakrsna referenca + Current Position Trenutni položaj + Current style: {style} Trenutni stil: {style} + Custom Margins… Prilagođene margine… + Custom Watermark Prilagođen vodeni žig + Custom Watermark… Prilagođen vodeni žig… + Customize Quick Access Toolbar Prilagodi traku za brzi pristup + Cut Iseci + Dark Mode Tamni režim + Dashed Isprekidano + Data Podaci + Date & Time Datum i vreme + Decrease Font Size Smanji veličinu fonta + Decrease Indent Smanji uvlačenje + Default Podrazumevano + Define New Bullet Definiši novu oznaku + Define New Number Format Definiši novi format broja + Delete Obriši + Delete Cells Obriši ćelije + Delete Columns Obriši kolone + Delete Comment Obriši komentar + Delete Next Word Obriši sledeću reč + Delete Previous Word Obriši prethodnu reč + Delete Rows Obriši redove + Delete Style Obriši stil + Delete Table Obriši tabelu + Design Dizajn + development build razvojna verzija + Diagonal Dijagonalno + Diamond Dijamant + Dictate Diktiranje + Different First Page Drugačija prva strana + Different Odd & Even Pages Drugačije neparne i parne strane + Discord Discord + Discord community Zajednica na Discord-u + Display Prikaz + Display for Review Prikaz za pregled + Distribute Columns Ravnomerno razmesti kolone + Distribute Rows Ravnomerno razmesti redove + Distributed Rasprostrto + Do Not Show in TOC Ne prikazuj u sadržaju + Do you want to save changes to {name}? Želite li da sačuvate izmene u {name}? + Don't Save Ne čuvaj + Document End Kraj dokumenta + Document Formatting Formatiranje dokumenta + Document Property Svojstvo dokumenta + Document Start Početak dokumenta + Document Text Tekst dokumenta + Document Title Naslov dokumenta + Documents you open will show up here. Dokumenti koje otvorite pojaviće se ovde. + Dot-dash Tačka-crta + Dotted Tačkasto + Double Duplo + Double Spacing Duplo proređenje + Double Strikethrough Duplo precrtano + Double Underline Duplo podvučeno + Down Dole + Draft Nacrt + Draw Crtanje + Drawing Tools Alati za crtanje + Drawing\nCanvas Platno\nza crtanje + Drop Cap Ukrasno veliko slovo + Edit Footer Uredi podnožje + Edit Header Uredi zaglavlje + Edit Recipient List Uredi listu primalaca + Edit\nRecipient List Uredi\nlistu primalaca + Edited Izmenjeno + Editing Uređivanje + Editing header/footer Uređivanje zaglavlja/podnožja + Editing — edit the document directly Uređivanje — menjajte dokument direktno + Editor Urednik + Effects Efekti + Emboss Reljef + Encrypt with Password Šifruj lozinkom + English (United States) Engleski (SAD) + Engrave Urezano + Enter page number: Unesite broj strane: + Envelopes Koverte + Equation Jednačina + Eraser Gumica + Even Page Parna strana + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Svaka naredba je dostupna skriptama i AI agentima: pokrenite `wordcraft-cli mcp` za MCP server, ili pokrenite aplikaciju sa `--control ` za JSON kontrolni kanal. + Export Izvoz + Export a copy to share (PDF, Word document) Izvezi kopiju za deljenje (PDF, Word dokument) + Export Page as PNG Izvezi stranu kao PNG + Export PDF Izvezi PDF + Exported {path} Izvezeno: {path} + Extend Selection Proširi izbor + Field Polje + File Fajl + Find Pronađi + Find and Replace Pronađi i zameni + Find Next Pronađi sledeće + Find Previous Pronađi prethodno + Find Recipient Pronađi primaoca + Find what: Pronađi: + Finish Završi + Finish & Merge Završi i spoji + Finish &\nMerge Završi\ni spoji + First Column Prva kolona + First commit Prvi commit + First line: Prvi red: + Fixed Column Width Fiksna širina kolone + Focus Fokus + Font Font + Font Color Boja fonta + Font color: Boja fonta: + Font Dialog Dijalog fonta + Font Size Veličina fonta + Font: Font: + Fonts Fontovi + Footer Podnožje + Footnote and Endnote Fusnota i endnota + Footnotes Fusnote + Format Format + Format Page Numbers Format brojeva strana + Format Page Numbers… Format brojeva strana… + Format Painter Kopiranje formata + Formatting at Selection Formatiranje na izboru + Formula Formula + General Opšte + Go To Idi na + Go to Idi na + Go To (⌘⌥G) Idi na (⌘⌥G) + Grab bag Razno + Greeting Line Red pozdrava + Greeting\nLine Red\npozdrava + Gridlines Linije mreže + Group Grupisanje + Grow Font 1 Point Uvećaj font za 1 tačku + Hanging Indent Uvlačenje viseće linije + Header Zaglavlje + Header & Footer Zaglavlje i podnožje + Header Row Red zaglavlja + Header/Footer Position Položaj zaglavlja/podnožja + Heading 1 Naslov 1 + Heading 2 Naslov 2 + Heading 3 Naslov 3 + Headings Naslovi + Heart Srce + Help Pomoć + Hidden Skriveno + Highlight Merge Fields Istakni polja za spajanje + Highlight\nMerge Fields Istakni\npolja za spajanje + Highlighter Marker + Home Početak + Horizontal Horizontalno + Horizontal Line Horizontalna linija + Hyphenation Deljenje reči + Icons Ikonice + Ignore All Zanemari sve + Illustrations Ilustracije + Immersive Imerzivno + Immersive Reader Imerzivni čitač + Immersive\nReader Imerzivni\nčitač + Increase Font Size Povećaj veličinu fonta + Increase Indent Povećaj uvlačenje + Indent Uvlačenje + Indentation Uvlačenje + Indents Uvlačenja + Index Indeks + Info Informacije + Ink to\nMath Rukopis u\nformulu + Ink to\nShape Rukopis u\noblik + Ink\nReplay Rukopis\nrepriza + Insert Umetanje + Insert Above Umetni iznad + Insert Below Umetni ispod + Insert Caption Umetni natpis + Insert Citation Umetni citat + Insert Endnote Umetni endnotu + Insert Footnote Umetni fusnotu + Insert Hyperlink Umetni hiperveze + Insert Index Umetni indeks + Insert Left Umetni levo + Insert Merge Field Umetni polje za spajanje + Insert Merge\nField Umetni polje\nza spajanje + Insert Right Umetni desno + Insert Shapes Umetni oblike + Insert Table Umetni tabelu + Insert Table of Authorities Umetni spisak izvora + Insert Table of Figures Umetni spisak slika + Insert Table… Umetni tabelu… + Insert\nAbove Umetni\niznad + Insert\nCaption Umetni\nnatpis + Insert\nCitation Umetni\ncitat + Insert\nFootnote Umetni\nfusnotu + Inside Borders Unutrašnje ivice + Inspect Document Pregledaj dokument + Interface language: Jezik interfejsa: + Italic Kurziv + Join the ArtCraft community on Discord Pridružite se ArtCraft zajednici na Discord-u + Join us on Discord: discord.gg/artcraft Pridružite nam se na Discord-u: discord.gg/artcraft + Justify Obostrano poravnanje + Keep Lines Together Zadrži redove zajedno + Keep lines together Zadrži redove zajedno + Keep Text Only Zadrži samo tekst + Keep with Next Zadrži sa sledećim + Keep with next Zadrži sa sledećim + Labels Nalepnice + Landscape Položeno + Language Jezik + Lasso Laso + Last Column Zadnja kolona + Last commit Zadnji commit + Layout Raspored + Layout Summary Pregled rasporeda + Left Levo + Left Border Leva ivica + Left: Levo: + Letter Pismo + Line Linija + Line and Page Breaks Prekidi redova i strana + Line and Paragraph Spacing Razmak između redova i pasusa + Line Break Prekid reda + Line delta Promena broja redova + Line End Kraj reda + Line Numbers Brojevi redova + Line Spacing Proređenost + Line Spacing Options… Opcije proređenosti… + Line spacing: Proređenost: + Line Start Početak reda + Lines Redovi + Lines added Dodati redovi + Lines deleted Obrisani redovi + Link Veza + Link to Previous Veza sa prethodnim + Links Veze + Location: {path} Lokacija: {path} + lowercase malim slovima + Macros Makroi + Mailings Pisma + Manage Sources Upravljaj izvorima + Many pages Više strana + Margins Margine + Mark Citation Označi citat + Mark Entry Označi stavku + Mark\nCitation Označi\ncitat + Mark\nEntry Označi\nstavku + Markdown (*.md) Markdown (*.md) + Match case Osetljivo na velika/mala slova + Match Fields Uporedi polja + Matrix Matrica + Merge Spoji + Merge Cells Spoji ćelije + Merge Formatting Spoji formatiranje + Merged PRs Spojeni PR-ovi + Mirrored Zrcaljeno + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Autorska prava (c) 2026 ArtCraft Team i saradnici na WordCraft-u. + Moderate Umereno + Modify Style Izmeni stil + Modify… Izmeni… + Move or Resize Pomeri ili promeni veličinu + Multilevel List Višenivoska lista + Multiple Pages Više strana + Name (A–Z) Naziv (A–Š) + Name: Naziv: + Narrow 0.5" all Uske 0,5" svuda + Navigation Navigacija + Navigation Pane Okno za navigaciju + New Novo + New Comment Novi komentar + New from Template Novo iz šablona + New Paragraph Novi pasus + New Style… Novi stil… + New Window Novi prozor + New\nComment Novi\nkomentar + New\nWindow Novi\nprozor + Next Sledeće + Next Change Sledeća izmena + Next Comment Sledeći komentar + Next Footnote Sledeća fusnota + Next Page Sledeća strana + Next Record Sledeći zapis + Next Section Sledeći odeljak + No Border Bez ivice + No Color Bez boje + No contributor data was built into this copy. Ova verzija ne sadrži podatke o saradnicima. + No Markup Bez izmena + No model credits were built into this copy. Ova verzija ne sadrži zasluge za AI modele. + No Paragraph Space Bez razmaka pasusa + Nonbreaking Hyphen Nedeljiva crtica + Nonbreaking Space Nedeljivi razmak + None Nijedno + Normal 1" all Normalne 1" svuda + Normal Style Normalan stil + Not available right now Trenutno nedostupno + Not saved Nije sačuvano + Not saved yet Još nije sačuvano + Nudge Mikro-pomeranje + Number of columns: Broj kolona: + Number of rows: Broj redova: + Numbering Numeracija + Object Objekat + Odd Page Neparna strana + Office 2003 Default Office 2003 podrazumevano + OK U redu + One Jedna + One Page Jedna strana + Only web (http, https) and email (mailto) links can be followed Mogu se otvoriti samo veb (http, https) i veze za e-poštu (mailto) + Open Otvori + OpenDocument Text (*.odt) OpenDocument tekst (*.odt) + Optional Hyphen Opcionalna crtica + Options Opcije + Orientation Orijentacija + Outline Kontura + Outline Level Nivo konture + Outside Borders Spoljašnje ivice + Oval Ovalno + Page Strana + Page Background Pozadina strane + Page Borders Ivice strane + Page Break Prekid strane + Page Break Before Prekid strane pre + Page break before Prekid strane pre + Page Breaks Prekidi strana + Page Color Boja strane + Page Down Strana nadole + Page image (*.png) Slika strane (*.png) + Page Movement Kretanje stranama + Page Number Broj strane + Page Setup Podešavanje strane + Page Up Strana nagore + Page Width Širina strane + Page width Širina strane + Page X of Y Strana X od Y + Page {page} Strana {page} + Page {page} of {pages} Strana {page} od {pages} + Page\nBorders Ivice\nstrane + Page\nColor Boja\nstrane + Page\nNumber Broj\nstrane + Pages Strane + Paragraph Pasus + Paragraph Details Detalji pasusa + Paragraph Down Pasus nadole + Paragraph Settings Podešavanja pasusa + Paragraph Spacing Razmak pasusa + Paragraph Up Pasus nagore + Paragraphs Pasusi + Paste Nalepi + Paste (⌘V) Nalepi (⌘V) + Paste Formatting Nalepi formatiranje + Paste: Keep Text Only Nalepi: samo tekst + Paste: Merge Formatting Nalepi: spoji formatiranje + PDF document (*.pdf) PDF dokument (*.pdf) + Pen Pero + Pencil Olovka + Picture Border Ivica slike + Picture Format Format slike + Picture Styles Stilovi slike + Pictures Slike + Plain text (*.txt) Običan tekst (*.txt) + Portrait Uspravno + Position Položaj + Preview Results Pregled rezultata + Preview\nResults Pregled\nrezultata + Previous Prethodno + Previous Change Prethodna izmena + Previous Comment Prethodni komentar + Previous Record Prethodni zapis + Previous Section Prethodni odeljak + Print Štampaj + Print Layout Prikaz za štampu + Print\nLayout Prikaz\nza štampu + Proofing Provera pisanja + Proofing Issues Problemi u pisanju + Properties Svojstva + Protect Zaštiti + Protect Document Zaštiti dokument + Quick Access Toolbar Traka za brzi pristup + Quick Parts Brzi delovi + Quick Tables Brze tabele + Read Aloud Čitanje naglas + Read Mode Režim čitanja + Read\nAloud Čitanje\nnaglas + Read\nMode Režim\nčitanja + Recent Nedavno + Record Macro Snimi makro + Recover Unsaved Documents Vrati nesačuvane dokumente + Rectangle Pravougaonik + Redo Ponovi + Reduce Hanging Indent Smanji uvlačenje viseće linije + References Referentni materijal + Reject Odbij + Reject All Changes Odbij sve izmene + Reject Change Odbij izmenu + Relaxed Opušteno + Remove Background Ukloni pozadinu + Remove Footer Ukloni podnožje + Remove Header Ukloni zaglavlje + Remove Hyperlink Ukloni hipervezu + Remove Space After Paragraph Ukloni razmak posle pasusa + Remove Table of Contents Ukloni sadržaj + Remove Watermark Ukloni vodeni žig + Repeat Ponovi + Repeat Header Rows Ponovi redove zaglavlja + Replace Zameni + Replace All Zameni sve + Replace Document Text Zameni tekst dokumenta + Replace with: Zameni sa: + Replay Repriza + Reply Odgovori + Report Izveštaj + Research Istraživanje + Researcher Istraživač + Reset Picture Resetuj sliku + Resolve Reši + Resolved Rešeno + Restart at 1 Ponovo počni od 1 + Restart Each Page Ponovo počni na svakoj strani + Restart Each Section Ponovo počni u svakom odeljku + Restrict Editing Ograniči uređivanje + Restrict\nEditing Ograniči\nuređivanje + Results Rezultati + Reverse the order Obrni redosled + Review Pregled + Reviewing Pregledanje + Reviewing Pane Okno za pregled + Reviewing — edits become suggestions Pregledanje — izmene postaju predlozi + Rich Text Format (*.rtf) Rich Text format (*.rtf) + Right Desno + Right Border Desna ivica + Right-to-Left Text Direction Smer teksta sa desna na levo + Left-to-Right Text Direction Smer teksta sa leva na desno + Right: Desno: + Rotate Rotiraj + Rounded Rectangle Zaokružen pravougaonik + Rows & Columns Redovi i kolone + Ruler Lenjir + Rules Pravila + Résumé Radna biografija + Save Sačuvaj + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Sačuvaj kopiju u drugom formatu. Štampanje ide preko PDF-a koji možete odštampati iz bilo kog pregledača. + Save As Sačuvaj kao + Save as Template Sačuvaj kao šablon + Saved Sačuvano + Search Commands Pretraga naredbi + Search commands and help Pretraži naredbe i pomoć + Search document Pretraži dokument + Section Breaks Prekidi odeljaka + Section Properties Svojstva odeljka + Sections Odeljci + Select Izaberi + Select All Izaberi sve + Select Cell Izaberi ćeliju + Select Line Izaberi red + Select Objects Izaberi objekte + Select Paragraph Izaberi pasus + Select Range Izaberi opseg + Select Recipients Izaberi primaoce + Select Row Izaberi red + Select Sentence Izaberi rečenicu + Select Table Izaberi tabelu + Select Text Izaberi tekst + Select Text with Similar Formatting Izaberi tekst sa sličnim formatiranjem + Select Word Izaberi reč + Select\nRecipients Izaberi\nprimaoce + Selection Izbor + Selection Pane Okno izbora + Send Backward Pomeri nazad + Sentence case. Veliko slovo na početku rečenice. + Set as Default Postavi kao podrazumevano + Set Caret Postavi kursor + Set Character Formatting Postavi formatiranje znakova + Set Numbering Value Postavi vrednost numeracije + Set Paragraph Formatting Postavi formatiranje pasusa + Shading Senčenje + Shadow Senka + Shape Fill Popuna oblika + Shape Format Format oblika + Shape Outline Kontura oblika + Shape Styles Stilovi oblika + Shapes Oblici + Share Deli + Shift+Tab Shift+Tab + Show Prikaži + Show all formatting marks Prikaži sve znake formatiranja + Show Comments Prikaži komentare + Show Markup Prikaži izmene + Show Notes Prikaži napomene + Show rulers Prikaži lenjire + Show the community button in the title bar Prikaži dugme zajednice u traci naslova + Show/Hide ¶ Prikaži/sakrij ¶ + Show\nComments Prikaži\nkomentare + Shrink Font 1 Point Smanji font za 1 tačku + Side to Side Jedno pored drugog + Side\nto Side Jedno\npored drugog + Signature Line Red za potpis + Single Jednostruko + Single Spacing Jednostruko proređenje + Size Veličina papira + Size: Veličina: + Small Caps Kapitelke + Small caps kapitelke + SmartArt SmartArt + Sort Sortiraj + Source code: github.com/storytold/wordcraft Izvorni kod: github.com/storytold/wordcraft + Space before/after: Razmak pre/posle: + Spacing Razmak + Spacing (pt): Razmak (pt): + Speech Govor + Spelling & Grammar Pravopis i gramatika + Spelling &\nGrammar Pravopis\ni gramatika + Spelling Suggestions Predlozi pravopisa + Split Podeli + Split Cells Podeli ćelije + Split Table Podeli tabelu + Spreadsheet Table Tabela proračunske table + Standard Colors Standardne boje + Star Zvezda + Start Mail Merge Pokreni spajanje dokumenata + Start Mail\nMerge Pokreni\nspajanje dokumenata + Statistics Statistika + Status Bar Statusna traka + Strikethrough Precrtano + Studio cover Naslovna strana studija + Studio handbook (sample) Priručnik studija (primer) + Style Stil + Style based on: Stil zasnovan na: + Style Set Skup stilova + Style: APA Stil: APA + Styles Stilovi + Styles Pane Okno stilova + Subscript Indeks dole + Superscript Indeks gore + Switch Modes Promeni režim + Switch\nModes Promeni\nrežim + Symbol Simbol + Symbols Simboli + Synonyms Sinonimi + Tab Tab + Table Tabela + Table Column Width Širina kolone tabele + Table Design Dizajn tabele + Table Layout Raspored tabele + Table of Authorities Spisak izvora + Table of Contents Sadržaj + Table of\nContents Sadržaj + Table Properties Svojstva tabele + Table Row Height Visina reda tabele + Table Style Options Opcije stila tabele + Table Styles Stilovi tabele + Tables Tabele + Tabs Tabulatori + Tabular List Tabelarna lista + Text Tekst + Text Box Okvir za tekst + Text Direction Smer teksta + Text Effects Efekti teksta + Text Effects and Typography Efekti teksta i tipografija + Text from File Tekst iz fajla + Text Highlight Color Boja isticanja teksta + Text to display: Tekst za prikaz: + Text Wrapping Prelamanje teksta + Text: Tekst: + Text\nBox Okvir\nza tekst + Text\nDirection Smer\nteksta + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Taj format ne čuva sve, pa je dokument i dalje otvoren. Sačuvajte ga kao Word dokument (.docx) da biste nastavili. + The new style takes the formatting of the current paragraph. Novi stil preuzima formatiranje trenutnog pasusa. + Theme Colors Boje teme + Theme Fonts Fontovi teme + Theme List Lista tema + Themes Teme + Thesaurus Rečnik sinonima + Thick Debelo + Three Tri + Tight Tesno + Top Border Gornja ivica + Top of Page Vrh strane + Top: Gore: + Total Row Red ukupno + Track Changes Praćenje izmena + Track Changes: On Praćenje izmena: uključeno + Tracking Praćenje + Translate Prevedi + Transparency Transparentnost + Triangle Trougao + Two Dva + Type a command, e.g. "insert table" Unesite naredbu, npr. "umetni tabelu" + Type Text Unesi tekst + Underline Podvučeno + Underline Words Only Podvuci samo reči + Undo Opozovi + Undo {action} Opozovi {action} + Up Gore + Update Field Ažuriraj polje + Update Index Ažuriraj indeks + Update Style to Match Selection Ažuriraj stil prema izboru + Update Table Ažuriraj sadržaj + Update Table of Figures Ažuriraj spisak slika + Update to Match Ažuriraj da odgovara + Update to Match Selection Ažuriraj prema izboru + UPPERCASE VELIKA SLOVA + User Name Korisničko ime + User name: Korisničko ime: + Version History Istorija verzija + Version {version} ({build}) Verzija {version} ({build}) + Vertical Uspravno + Vertical Alignment Uspravno poravnanje + View Prikaz + View Gridlines Prikaži linije mreže + View State Stanje prikaza + Views Prikazi + Voice Glas + Watermark Vodeni žig + Wave Talasasto + Web Layout Veb raspored + Web page (*.html) Veb strana (*.html) + Web\nLayout Veb\nraspored + Whole page Cela strana + Whole words Samo cele reči + Wide Široko + Widow/Orphan Control Kontrola usamljenih redova + Widow/Orphan control Kontrola usamljenih redova + Wildcards (regex) Džoker znaci (regex) + Window Prozor + Word Count Broj reči + Word document (*.docx) Word dokument (*.docx) + Word Left Reč levo + Word Right Reč desno + WordArt WordArt + WordCraft WordCraft + Words Reči + Words only Samo reči + Wrap Text Prelomi tekst + Wrap\nText Prelomi\ntekst + Write & Insert Fields Pisanje i umetanje polja + Zoom Zumiranje + Zoom In Uvećaj + Zoom Out Umanji + {group} settings Podešavanja za {group} + {key}+Click to follow link {key}+klik za otvaranje veze + ➕ New comment ➕ Novi komentar + 📂 Browse… 📂 Potraži… + {words} words {words} reči + {selected} of {words} words {selected} od {words} reči + Direction: Smer: + Right-to-left Sa desna na levo + Left-to-right Sa leva na desno +# Zotero integration + Zotero Zotero + Citations Citati + Document Dokument + Add/Edit\nCitation Dodaj/izmeni\ncitat + Add\nNote Dodaj\nbelešku + Add/Edit\nBibliography Dodaj/izmeni\nbibliografiju + Refresh Osveži + Document\nPreferences Postavke\ndokumenta + Unlink\nCitations Ukloni vezu\nsa citatima + Waiting for Zotero… Čeka se Zotero… + Zotero: done Zotero: završeno + Zotero: cancelled Zotero: otkazano + Yes Da + No Ne + Move Past Citation Pomeri iza citata + Move Past\nCitation Pomeri iza\ncitata + Next Sentence Sledeća rečenica + Previous Sentence Prethodna rečenica + Next sentence Sledeća rečenica + Previous sentence Prethodna rečenica + Play/Pause Pusti/pauziraj + Read Aloud Status Status čitanja naglas + Reading Speed Brzina čitanja + Skip Citations and Bibliography Preskoči citate i bibliografiju + Skip citations & bibliography Preskoči citate i bibliografiju + Stop Reading Zaustavi čitanje + Don't read Zotero, Mendeley or EndNote citations and bibliographies Ne čitaj citate i bibliografiju iz Zotero, Mendeley ili EndNote + Play Pusti + Pause Pauziraj + Stop Zaustavi + Sentence Rečenica + Paused Pauzirano + Finished Završeno + Stopped Zaustavljeno + Speed Brzina + Normal speed Normalna brzina + Your changes will be lost if you don't save them. Vaše izmene će biti izgubljene ako ih ne sačuvate. + Scroll ribbon left Pomeri traku levo + Scroll ribbon right Pomeri traku desno +# Equations. + Change Structure Promeni strukturu + Change to Display Promeni u prikaznu formulu + Change to Inline Promeni u tekstualnu formulu + Conversions Konverzije + Delete Back in Equation Obriši unazad u formuli + Delete in Equation Obriši u formuli + Edit Equation Uredi formulu + Enter in Equation Enter u formuli + Equation Gallery Galerija formula + Equation Info Informacije o formuli + Equation Number Broj formule + Input Format Format unosa + Insert Structure Umetni strukturu + Insert Symbol Umetni simbol + Justification Poravnanje + Leave Equation Izađi iz formule + Move in Equation Kretanje u formuli + Normal Text Običan tekst + Set Equation Postavi formulu + Structure Actions Radnje sa strukturom + Structures Strukture + Tools Alati + Type in Equation Unos u formuli + Inline Tekstualna + Number Broj + Close Equation Zatvori formulu + Insert New Equation Umetni novu formulu + Built-In Ugrađeno + Professional Profesionalni prikaz + Linear Linearni prikaz + Fraction Razlomak + Script Indeks + Radical Korenovanje + Integral Integral + Large\nOperator Veliki\noperator + Bracket Zagrada + Function Funkcija + Accent Znak akcenta + Limit and\nLog Limes i\nlogaritam + Operator Operator + Equation Options Opcije formule + All symbol sets Svi skupovi simbola + Send the document straight to the system print dialog, or save a copy in another format below. Šalje dokument direktno na sistemski dijalog za štampu, ili sačuvajte kopiju u drugom formatu ispod. + Or save a copy in another format: Ili sačuvaj kopiju u drugom formatu: + Opening print dialog… Otvara se dijalog za štampu… + Print failed: {error} Štampanje neuspelo: {error} + Couldn't open {path}: {error} Nije moguće otvoriti {path}: {error} + More templates → Još šablona → + No templates match your search. Nijedan šablon ne odgovara pretrazi. + Search for templates Pretraži šablone + Column Selection Izbor kolone + Style Inspector Inspektor stilova + Inspect Styles at Selection Pregledaj stilove izbora + Clear Formatting Level Obriši nivo formatiranja + Characters Znakovi + Direct formatting Direktno formatiranje + Reset to Normal Vrati na normalan stil + Clear Paragraph Formatting Obriši formatiranje pasusa + Clear Character Style Obriši stil znakova + Clear Character Formatting Obriši formatiranje znakova + Clear All Obriši sve + Paste Special Specijalno lepljenje + Paste Special… Specijalno lepljenje… + Paste as: Nalepi kao: + The clipboard is empty. Ostava je prazna. + Formatted Text Formatirani tekst + Rich Text Format (RTF) Obogaćeni tekst (RTF) + HTML Format HTML format + Unformatted Text Neformatirani tekst + Result Rezultat + Keeps the fonts and formatting of the copied text. Zadržava fontove i formatiranje kopiranog teksta. + Reads the clipboard's rich text and keeps its formatting. Čita obogaćeni tekst (RTF) iz ostave i zadržava njegovo formatiranje. + Reads the clipboard's web content and keeps its formatting. Čita veb sadržaj iz ostave i zadržava njegovo formatiranje. + Drops all formatting and pastes plain text. Uklanja sve formatiranje i lepi običan tekst. + Advanced Find Napredna pretraga + Advanced Find… Napredna pretraga… + Go To… Idi na… + Reading Highlight Isticanje rezultata + New Table Style Novi stil tabele + Modify Table Style Izmeni stil tabele + New Table Style… Novi stil tabele… + Modify Table Style… Izmeni stil tabele… + Apply formatting to: Primeni formatiranje na: + Whole Table Cela tabela + Fill color: Boja popune: + {count} items highlighted Istaknuto stavki: {count} + {n} pt {n} pt + Not {name} Bez: {name} + Level {n} Nivo {n} + Body Text Osnovni tekst + {n} lines Redova: {n} + At least {n} Najmanje {n} + Exactly {n} Tačno {n} + Centered Centrirano + Character spacing Razmak između znakova + Don't add space between paragraphs of the same style Bez razmaka između pasusa istog stila + Don't hyphenate Bez deljenja reči + Double strikethrough Duplo precrtano + Drop cap Ukrasno veliko slovo + First line Prvi red + Font color Boja fonta + Highlight Isticanje + Justified Obostrano poravnato + Left indent Uvlačenje sleva + Line spacing Proređenost + Outline level Nivo konture + Raised/lowered Podignuto/spušteno + Right indent Uvlačenje zdesna + Scale Razmera + Space after Razmak posle + Space before Razmak pre + Suppress line numbers Bez brojeva redova + Underline color Boja podvlačenja + Interface theme: Tema interfejsa: + Interface\nTheme Tema\ninterfejsa + Interface Theme Tema interfejsa + Light Svetla + Dark Tamna + Use system setting Prema podešavanju sistema + Letters Pisma + E-mail Messages Poruke e-pošte + Directory Imenik + Normal Word Document Običan Word dokument + If…Then…Else… Ako…Onda…Inače… + If…Then…Else Ako…Onda…Inače + Merge Record # Broj zapisa spajanja + Skip Record If… Preskoči zapis ako… + Skip Record If Preskoči zapis ako + Equals: Jednako: + Then insert: Onda umetni: + Otherwise insert: Inače umetni: + Courtesy Title Oslovljavanje + First Name Ime + Last Name Prezime + Company Preduzeće + Address Adresa + City Grad + State Država/pokrajina + Postal Code Poštanski broj + Country Zemlja + (not matched) (nije upareno) + Leave a recipient out of the merge when a field has this value. Izostavi primaoca iz spajanja kada polje ima ovu vrednost. + Insert one text or another, depending on a field's value. Umetni jedan ili drugi tekst, zavisno od vrednosti polja. + Select recipients first to match their fields. Prvo izaberite primaoce da biste uparili njihova polja. + The recipient-list field that fills each part of an address block and greeting line. Polje liste primalaca koje popunjava svaki deo bloka adrese i reda pozdrava. + No errors found: every merge field is in the recipient list. Nema grešaka: svako polje za spajanje postoji u listi primalaca. + These merge fields aren't in the recipient list: Ova polja za spajanje nisu u listi primalaca: + Add at least one field. Dodajte bar jedno polje. + Add Field Dodaj polje + Every field needs a name. Svako polje mora imati naziv. + Field name Naziv polja + Field names must be different. Nazivi polja moraju biti različiti. + Fields: Polja: + Field: Polje: + Find: Pronađi: + New Entry Nova stavka + Recipient List Lista primalaca + Recipients: {count} Primalaca: {count} + Remove field Ukloni polje + Remove recipient Ukloni primaoca + Select recipients first to choose from their fields. Prvo izaberite primaoce da biste birali među njihovim poljima. + Type a New List… Unesi novu listu… + Type at least one recipient. Unesite bar jednog primaoca. + Type the recipients, or load them from a file. Unesite primaoce ili ih učitajte iz fajla. + Use an Existing List… Koristi postojeću listu… + A file dialog is already open. Dijalog za fajlove je već otvoren. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv new file mode 100644 index 000000000..511569f7f --- /dev/null +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -0,0 +1,1012 @@ +# Serbian, Cyrillic script (sr) interface catalog. Transliterated from sr-latn.tsv (see that file +# for the clean-room translation itself); change both files together so they don't drift apart. +# Product names, key names, shortcuts, code, URLs, schemes and file extensions stay in Latin +# letters here too (`wordcraft-cli mcp`, Backspace, ⌘V, .docx, http, Markdown). +# +# Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# computing vocabulary. No Microsoft Word (or other product) localisation, string table or +# translated UI was consulted or copied; add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. + + (no suggestions) (нема предлога) + 1.5 Line Spacing Проређеност 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Бесплатан, отворени обрађивач текста написан од нуле у Rust-у.\nДео Crafting Apps пакета тима ArtCraft. + About О програму + About WordCraft О WordCraft-у + Accept Прихвати + Accept All Changes Прихвати све измене + Accept This Change Прихвати ову измену + Accessibility Приступачност + Add Додај + Add a comment Додај коментар + Add a Table Додај табелу + Add Space Before Paragraph Додај размак пре пасуса + Add Text Додај текст + Add to Dictionary Додај у речник + Add to Style Gallery Додај у галерију стилова + Address Block Блок адресе + Address: Адреса: + Address\nBlock Блок\nадресе + Adjust Подеси + Advanced Напредно + After: После: + Agents Агенти + Align Поравнање + Align Left Лево поравнање + Align Right Десно поравнање + Alignment Поравнање + All Borders Све ивице + All Caps Сва велика слова + All caps Сва велика слова + All Markup Све измене + Alt Text Алтернативни текст + Apply Character Style Примени стил знакова + Apply Style Примени стил + Apply Styles Изабери и примени стилове + Arrange Ређање + Arrange\nAll Распореди\nсве + Arrow Стрелица + Artistic Effects Уметнички ефекти + Author Аутор + AutoCorrect Options Опције аутоматске исправке + AutoFit Аутоматско уклапање + AutoFit Contents Аутоматски уклопи према садржају + AutoFit Window Аутоматски уклопи према прозору + Automatic Аутоматски + Automatic ({language}) Аутоматски ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. Аутоматски прати језик система. Менији и наредбе се мењају; документи не. + Automatic Table 1 (3 levels) Аутоматска табела 1 (3 нивоа) + Automatic Table 2 (2 levels) Аутоматска табела 2 (2 нивоа) + AutoSave Аутоматско чување + AutoSave documents you have saved in WordCraft Аутоматски чувај документе које сте већ сачували у WordCraft-у + AutoSave failed: {error}. Save the document to turn AutoSave back on. Аутоматско чување неуспело: {error}. Сачувајте документ да бисте га поново укључили. + Dark page (white text on black) Тамна страница (бео текст на црном) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Прикажи документе са инвертованим бојама, као код Приказ › Промени режим. Чување, штампање и PDF нису погођени. + AutoSave saves every change to the file (needs a saved document) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван) + AutoText Аутоматски текст + Backspace Backspace + Banded Columns Тракасте колоне + Banded Rows Тракасти редови + Before: Пре: + Bibliography Библиографија + Binary assets added Додати бинарни ресурси + Binary assets removed Уклоњени бинарни ресурси + Blank Празно + Blank (Three Columns) Празно (три колоне) + Blank document Празан документ + Blank Page Празна страна + Block\nAuthors Блокирај\nауторе + Bold Подебљано + Bookmark Обележивач + Bookmark name: Назив обележивача: + Border Painter Копирање ивица + Border\nPainter Копирање\nивица + Borders Ивице + Bottom Border Доња ивица + Bottom of Page Дно стране + Bottom: Доле: + Box Оквир + Breaks Прекиди + Bring Forward Помери напред + Bullet Library Библиотека ознака + Bullets Ознаке + Calendar Календар + Cancel Откажи + Capitalize Each Word Велико почетно слово сваке речи + Captions Натписи + Cell Margins Маргине ћелије + Cell Size Величина ћелије + Cell\nMargins Маргине\nћелије + Center Центрирај + Change Измени + Change Case Промени величину слова + Change List Level Промени ниво листе + Change Picture Промени слику + Change Shape Промени облик + Changes Измене + Character Position Положај знака + Character Scale Размера знака + Character Shading Сенчење знакова + Character Spacing Размак између знакова + Character Border Ивица знака + Characters (no spaces) Знакови (без размака) + Characters (with spaces) Знакови (са размацима) + Chart Графикон + Check Accessibility Провера приступачности + Check Compatibility Провера компатибилности + Check for Errors Провера грешака + Check Spelling as You Type Провера правописа док пишете + Check\nAccessibility Провера\nприступачности + Citation Style Стил цитирања + Citations & Bibliography Цитати и библиографија + Clear Обриши + Clear All Formatting Обриши све форматирање + Clipboard Остава + Close Затвори + Close Header and Footer Затвори заглавље и подножје + Collapse Selection Скупи избор + Color Боја + Color: Боја: + Colors Боје + Column Колона + Column Break Прекид колоне + Columns Колоне + Combine Споји + Commands Наредбе + Comment Коментар + Comments Коментари + Comments Pane Окно коментара + Community\nDiscord Заједница\nна Discord-у + Compact Сажето + Compare Упоређивање + Compress Pictures Компресуј слике + Continuous Непрекидно + Convert Претвори + Convert Text to Table Претвори текст у табелу + Convert Text to Table… Претвори текст у табелу… + Convert to Text Претвори у текст + Copy Копирај + Copy Formatting Копирај форматирање + Corrections Исправке + Count words in text boxes and notes Броји речи у оквирима за текст и фуснотама + Couldn't download {name}: {error} Преузимање {name} није успело: {error} + Cover Page Насловна страна + Create Направи + Create a Style Направи стил + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Направите интерактивни преглед документа.\n\nОдличан је начин да пратите где се налазите или брзо премештате садржај.\n\nЗа почетак, идите на картицу Почетак и примените стилове наслова на наслове у документу. + Create New Style Направи нови стил + Crop Исеци + Cross-reference Унакрсна референца + Current Position Тренутни положај + Current style: {style} Тренутни стил: {style} + Custom Margins… Прилагођене маргине… + Custom Watermark Прилагођен водени жиг + Custom Watermark… Прилагођен водени жиг… + Customize Quick Access Toolbar Прилагоди траку за брзи приступ + Cut Исеци + Dark Mode Тамни режим + Dashed Испрекидано + Data Подаци + Date & Time Датум и време + Decrease Font Size Смањи величину фонта + Decrease Indent Смањи увлачење + Default Подразумевано + Define New Bullet Дефиниши нову ознаку + Define New Number Format Дефиниши нови формат броја + Delete Обриши + Delete Cells Обриши ћелије + Delete Columns Обриши колоне + Delete Comment Обриши коментар + Delete Next Word Обриши следећу реч + Delete Previous Word Обриши претходну реч + Delete Rows Обриши редове + Delete Style Обриши стил + Delete Table Обриши табелу + Design Дизајн + development build развојна верзија + Diagonal Дијагонално + Diamond Дијамант + Dictate Диктирање + Different First Page Другачија прва страна + Different Odd & Even Pages Другачије непарне и парне стране + Discord Discord + Discord community Заједница на Discord-у + Display Приказ + Display for Review Приказ за преглед + Distribute Columns Равномерно размести колоне + Distribute Rows Равномерно размести редове + Distributed Распрострто + Do Not Show in TOC Не приказуј у садржају + Do you want to save changes to {name}? Желите ли да сачувате измене у {name}? + Don't Save Не чувај + Document End Крај документа + Document Formatting Форматирање документа + Document Property Својство документа + Document Start Почетак документа + Document Text Текст документа + Document Title Наслов документа + Documents you open will show up here. Документи које отворите појавиће се овде. + Dot-dash Тачка-црта + Dotted Тачкасто + Double Дупло + Double Spacing Дупло проређење + Double Strikethrough Дупло прецртано + Double Underline Дупло подвучено + Down Доле + Draft Нацрт + Draw Цртање + Drawing Tools Алати за цртање + Drawing\nCanvas Платно\nза цртање + Drop Cap Украсно велико слово + Edit Footer Уреди подножје + Edit Header Уреди заглавље + Edit Recipient List Уреди листу прималаца + Edit\nRecipient List Уреди\nлисту прималаца + Edited Измењено + Editing Уређивање + Editing header/footer Уређивање заглавља/подножја + Editing — edit the document directly Уређивање — мењајте документ директно + Editor Уредник + Effects Ефекти + Emboss Рељеф + Encrypt with Password Шифруј лозинком + English (United States) Енглески (САД) + Engrave Урезано + Enter page number: Унесите број стране: + Envelopes Коверте + Equation Једначина + Eraser Гумица + Even Page Парна страна + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Свака наредба је доступна скриптама и AI агентима: покрените `wordcraft-cli mcp` за MCP сервер, или покрените апликацију са `--control ` за JSON контролни канал. + Export Извоз + Export a copy to share (PDF, Word document) Извези копију за дељење (PDF, Word документ) + Export Page as PNG Извези страну као PNG + Export PDF Извези PDF + Exported {path} Извезено: {path} + Extend Selection Прошири избор + Field Поље + File Фајл + Find Пронађи + Find and Replace Пронађи и замени + Find Next Пронађи следеће + Find Previous Пронађи претходно + Find Recipient Пронађи примаоца + Find what: Пронађи: + Finish Заврши + Finish & Merge Заврши и споји + Finish &\nMerge Заврши\nи споји + First Column Прва колона + First commit Први commit + First line: Први ред: + Fixed Column Width Фиксна ширина колоне + Focus Фокус + Font Фонт + Font Color Боја фонта + Font color: Боја фонта: + Font Dialog Дијалог фонта + Font Size Величина фонта + Font: Фонт: + Fonts Фонтови + Footer Подножје + Footnote and Endnote Фуснота и енднота + Footnotes Фусноте + Format Формат + Format Page Numbers Формат бројева страна + Format Page Numbers… Формат бројева страна… + Format Painter Копирање формата + Formatting at Selection Форматирање на избору + Formula Формула + General Опште + Go To Иди на + Go to Иди на + Go To (⌘⌥G) Иди на (⌘⌥G) + Grab bag Разно + Greeting Line Ред поздрава + Greeting\nLine Ред\nпоздрава + Gridlines Линије мреже + Group Груписање + Grow Font 1 Point Увећај фонт за 1 тачку + Hanging Indent Увлачење висеће линије + Header Заглавље + Header & Footer Заглавље и подножје + Header Row Ред заглавља + Header/Footer Position Положај заглавља/подножја + Heading 1 Наслов 1 + Heading 2 Наслов 2 + Heading 3 Наслов 3 + Headings Наслови + Heart Срце + Help Помоћ + Hidden Скривено + Highlight Merge Fields Истакни поља за спајање + Highlight\nMerge Fields Истакни\nпоља за спајање + Highlighter Маркер + Home Почетак + Horizontal Хоризонтално + Horizontal Line Хоризонтална линија + Hyphenation Дељење речи + Icons Иконице + Ignore All Занемари све + Illustrations Илустрације + Immersive Имерзивно + Immersive Reader Имерзивни читач + Immersive\nReader Имерзивни\nчитач + Increase Font Size Повећај величину фонта + Increase Indent Повећај увлачење + Indent Увлачење + Indentation Увлачење + Indents Увлачења + Index Индекс + Info Информације + Ink to\nMath Рукопис у\nформулу + Ink to\nShape Рукопис у\nоблик + Ink\nReplay Рукопис\nреприза + Insert Уметање + Insert Above Уметни изнад + Insert Below Уметни испод + Insert Caption Уметни натпис + Insert Citation Уметни цитат + Insert Endnote Уметни ендноту + Insert Footnote Уметни фусноту + Insert Hyperlink Уметни хипервезе + Insert Index Уметни индекс + Insert Left Уметни лево + Insert Merge Field Уметни поље за спајање + Insert Merge\nField Уметни поље\nза спајање + Insert Right Уметни десно + Insert Shapes Уметни облике + Insert Table Уметни табелу + Insert Table of Authorities Уметни списак извора + Insert Table of Figures Уметни списак слика + Insert Table… Уметни табелу… + Insert\nAbove Уметни\nизнад + Insert\nCaption Уметни\nнатпис + Insert\nCitation Уметни\nцитат + Insert\nFootnote Уметни\nфусноту + Inside Borders Унутрашње ивице + Inspect Document Прегледај документ + Interface language: Језик интерфејса: + Italic Курзив + Join the ArtCraft community on Discord Придружите се ArtCraft заједници на Discord-у + Join us on Discord: discord.gg/artcraft Придружите нам се на Discord-у: discord.gg/artcraft + Justify Обострано поравнање + Keep Lines Together Задржи редове заједно + Keep lines together Задржи редове заједно + Keep Text Only Задржи само текст + Keep with Next Задржи са следећим + Keep with next Задржи са следећим + Labels Налепнице + Landscape Положено + Language Језик + Lasso Ласо + Last Column Задња колона + Last commit Задњи commit + Layout Распоред + Layout Summary Преглед распореда + Left Лево + Left Border Лева ивица + Left: Лево: + Letter Писмо + Line Линија + Line and Page Breaks Прекиди редова и страна + Line and Paragraph Spacing Размак између редова и пасуса + Line Break Прекид реда + Line delta Промена броја редова + Line End Крај реда + Line Numbers Бројеви редова + Line Spacing Проређеност + Line Spacing Options… Опције проређености… + Line spacing: Проређеност: + Line Start Почетак реда + Lines Редови + Lines added Додати редови + Lines deleted Обрисани редови + Link Веза + Link to Previous Веза са претходним + Links Везе + Location: {path} Локација: {path} + lowercase малим словима + Macros Макрои + Mailings Писма + Manage Sources Управљај изворима + Many pages Више страна + Margins Маргине + Mark Citation Означи цитат + Mark Entry Означи ставку + Mark\nCitation Означи\nцитат + Mark\nEntry Означи\nставку + Markdown (*.md) Markdown (*.md) + Match case Осетљиво на велика/мала слова + Match Fields Упореди поља + Matrix Матрица + Merge Споји + Merge Cells Споји ћелије + Merge Formatting Споји форматирање + Merged PRs Спојени PR-ови + Mirrored Зрцаљено + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Ауторска права (c) 2026 ArtCraft Team и сарадници на WordCraft-у. + Moderate Умерено + Modify Style Измени стил + Modify… Измени… + Move or Resize Помери или промени величину + Multilevel List Вишенивоска листа + Multiple Pages Више страна + Name (A–Z) Назив (А–Ш) + Name: Назив: + Narrow 0.5" all Уске 0,5" свуда + Navigation Навигација + Navigation Pane Окно за навигацију + New Ново + New Comment Нови коментар + New from Template Ново из шаблона + New Paragraph Нови пасус + New Style… Нови стил… + New Window Нови прозор + New\nComment Нови\nкоментар + New\nWindow Нови\nпрозор + Next Следеће + Next Change Следећа измена + Next Comment Следећи коментар + Next Footnote Следећа фуснота + Next Page Следећа страна + Next Record Следећи запис + Next Section Следећи одељак + No Border Без ивице + No Color Без боје + No contributor data was built into this copy. Ова верзија не садржи податке о сарадницима. + No Markup Без измена + No model credits were built into this copy. Ова верзија не садржи заслуге за AI моделе. + No Paragraph Space Без размака пасуса + Nonbreaking Hyphen Недељива цртица + Nonbreaking Space Недељиви размак + None Ниједно + Normal 1" all Нормалне 1" свуда + Normal Style Нормалан стил + Not available right now Тренутно недоступно + Not saved Није сачувано + Not saved yet Још није сачувано + Nudge Микро-померање + Number of columns: Број колона: + Number of rows: Број редова: + Numbering Нумерација + Object Објекат + Odd Page Непарна страна + Office 2003 Default Office 2003 подразумевано + OK У реду + One Једна + One Page Једна страна + Only web (http, https) and email (mailto) links can be followed Могу се отворити само веб (http, https) и везе за е-пошту (mailto) + Open Отвори + OpenDocument Text (*.odt) OpenDocument текст (*.odt) + Optional Hyphen Опционална цртица + Options Опције + Orientation Оријентација + Outline Контура + Outline Level Ниво контуре + Outside Borders Спољашње ивице + Oval Овално + Page Страна + Page Background Позадина стране + Page Borders Ивице стране + Page Break Прекид стране + Page Break Before Прекид стране пре + Page break before Прекид стране пре + Page Breaks Прекиди страна + Page Color Боја стране + Page Down Страна надоле + Page image (*.png) Слика стране (*.png) + Page Movement Кретање странама + Page Number Број стране + Page Setup Подешавање стране + Page Up Страна нагоре + Page Width Ширина стране + Page width Ширина стране + Page X of Y Страна X од Y + Page {page} Страна {page} + Page {page} of {pages} Страна {page} од {pages} + Page\nBorders Ивице\nстране + Page\nColor Боја\nстране + Page\nNumber Број\nстране + Pages Стране + Paragraph Пасус + Paragraph Details Детаљи пасуса + Paragraph Down Пасус надоле + Paragraph Settings Подешавања пасуса + Paragraph Spacing Размак пасуса + Paragraph Up Пасус нагоре + Paragraphs Пасуси + Paste Налепи + Paste (⌘V) Налепи (⌘V) + Paste Formatting Налепи форматирање + Paste: Keep Text Only Налепи: само текст + Paste: Merge Formatting Налепи: споји форматирање + PDF document (*.pdf) PDF документ (*.pdf) + Pen Перо + Pencil Оловка + Picture Border Ивица слике + Picture Format Формат слике + Picture Styles Стилови слике + Pictures Слике + Plain text (*.txt) Обичан текст (*.txt) + Portrait Усправно + Position Положај + Preview Results Преглед резултата + Preview\nResults Преглед\nрезултата + Previous Претходно + Previous Change Претходна измена + Previous Comment Претходни коментар + Previous Record Претходни запис + Previous Section Претходни одељак + Print Штампај + Print Layout Приказ за штампу + Print\nLayout Приказ\nза штампу + Proofing Провера писања + Proofing Issues Проблеми у писању + Properties Својства + Protect Заштити + Protect Document Заштити документ + Quick Access Toolbar Трака за брзи приступ + Quick Parts Брзи делови + Quick Tables Брзе табеле + Read Aloud Читање наглас + Read Mode Режим читања + Read\nAloud Читање\nнаглас + Read\nMode Режим\nчитања + Recent Недавно + Record Macro Сними макро + Recover Unsaved Documents Врати несачуване документе + Rectangle Правоугаоник + Redo Понови + Reduce Hanging Indent Смањи увлачење висеће линије + References Референтни материјал + Reject Одбиј + Reject All Changes Одбиј све измене + Reject Change Одбиј измену + Relaxed Опуштено + Remove Background Уклони позадину + Remove Footer Уклони подножје + Remove Header Уклони заглавље + Remove Hyperlink Уклони хипервезу + Remove Space After Paragraph Уклони размак после пасуса + Remove Table of Contents Уклони садржај + Remove Watermark Уклони водени жиг + Repeat Понови + Repeat Header Rows Понови редове заглавља + Replace Замени + Replace All Замени све + Replace Document Text Замени текст документа + Replace with: Замени са: + Replay Реприза + Reply Одговори + Report Извештај + Research Истраживање + Researcher Истраживач + Reset Picture Ресетуј слику + Resolve Реши + Resolved Решено + Restart at 1 Поново почни од 1 + Restart Each Page Поново почни на свакој страни + Restart Each Section Поново почни у сваком одељку + Restrict Editing Ограничи уређивање + Restrict\nEditing Ограничи\nуређивање + Results Резултати + Reverse the order Обрни редослед + Review Преглед + Reviewing Прегледање + Reviewing Pane Окно за преглед + Reviewing — edits become suggestions Прегледање — измене постају предлози + Rich Text Format (*.rtf) Rich Text формат (*.rtf) + Right Десно + Right Border Десна ивица + Right-to-Left Text Direction Смер текста са десна на лево + Left-to-Right Text Direction Смер текста са лева на десно + Right: Десно: + Rotate Ротирај + Rounded Rectangle Заокружен правоугаоник + Rows & Columns Редови и колоне + Ruler Лењир + Rules Правила + Résumé Радна биографија + Save Сачувај + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Сачувај копију у другом формату. Штампање иде преко PDF-а који можете одштампати из било ког прегледача. + Save As Сачувај као + Save as Template Сачувај као шаблон + Saved Сачувано + Search Commands Претрага наредби + Search commands and help Претражи наредбе и помоћ + Search document Претражи документ + Section Breaks Прекиди одељака + Section Properties Својства одељка + Sections Одељци + Select Изабери + Select All Изабери све + Select Cell Изабери ћелију + Select Line Изабери ред + Select Objects Изабери објекте + Select Paragraph Изабери пасус + Select Range Изабери опсег + Select Recipients Изабери примаоце + Select Row Изабери ред + Select Sentence Изабери реченицу + Select Table Изабери табелу + Select Text Изабери текст + Select Text with Similar Formatting Изабери текст са сличним форматирањем + Select Word Изабери реч + Select\nRecipients Изабери\nпримаоце + Selection Избор + Selection Pane Окно избора + Send Backward Помери назад + Sentence case. Велико слово на почетку реченице. + Set as Default Постави као подразумевано + Set Caret Постави курсор + Set Character Formatting Постави форматирање знакова + Set Numbering Value Постави вредност нумерације + Set Paragraph Formatting Постави форматирање пасуса + Shading Сенчење + Shadow Сенка + Shape Fill Попуна облика + Shape Format Формат облика + Shape Outline Контура облика + Shape Styles Стилови облика + Shapes Облици + Share Дели + Shift+Tab Shift+Tab + Show Прикажи + Show all formatting marks Прикажи све знаке форматирања + Show Comments Прикажи коментаре + Show Markup Прикажи измене + Show Notes Прикажи напомене + Show rulers Прикажи лењире + Show the community button in the title bar Прикажи дугме заједнице у траци наслова + Show/Hide ¶ Прикажи/сакриј ¶ + Show\nComments Прикажи\nкоментаре + Shrink Font 1 Point Смањи фонт за 1 тачку + Side to Side Једно поред другог + Side\nto Side Једно\nпоред другог + Signature Line Ред за потпис + Single Једноструко + Single Spacing Једноструко проређење + Size Величина папира + Size: Величина: + Small Caps Капителке + Small caps капителке + SmartArt SmartArt + Sort Сортирај + Source code: github.com/storytold/wordcraft Изворни код: github.com/storytold/wordcraft + Space before/after: Размак пре/после: + Spacing Размак + Spacing (pt): Размак (пт): + Speech Говор + Spelling & Grammar Правопис и граматика + Spelling &\nGrammar Правопис\nи граматика + Spelling Suggestions Предлози правописа + Split Подели + Split Cells Подели ћелије + Split Table Подели табелу + Spreadsheet Table Табела прорачунске табле + Standard Colors Стандардне боје + Star Звезда + Start Mail Merge Покрени спајање докумената + Start Mail\nMerge Покрени\nспајање докумената + Statistics Статистика + Status Bar Статусна трака + Strikethrough Прецртано + Studio cover Насловна страна студија + Studio handbook (sample) Приручник студија (пример) + Style Стил + Style based on: Стил заснован на: + Style Set Скуп стилова + Style: APA Стил: APA + Styles Стилови + Styles Pane Окно стилова + Subscript Индекс доле + Superscript Индекс горе + Switch Modes Промени режим + Switch\nModes Промени\nрежим + Symbol Симбол + Symbols Симболи + Synonyms Синоними + Tab Tab + Table Табела + Table Column Width Ширина колоне табеле + Table Design Дизајн табеле + Table Layout Распоред табеле + Table of Authorities Списак извора + Table of Contents Садржај + Table of\nContents Садржај + Table Properties Својства табеле + Table Row Height Висина реда табеле + Table Style Options Опције стила табеле + Table Styles Стилови табеле + Tables Табеле + Tabs Табулатори + Tabular List Табеларна листа + Text Текст + Text Box Оквир за текст + Text Direction Смер текста + Text Effects Ефекти текста + Text Effects and Typography Ефекти текста и типографија + Text from File Текст из фајла + Text Highlight Color Боја истицања текста + Text to display: Текст за приказ: + Text Wrapping Преламање текста + Text: Текст: + Text\nBox Оквир\nза текст + Text\nDirection Смер\nтекста + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Тај формат не чува све, па је документ и даље отворен. Сачувајте га као Word документ (.docx) да бисте наставили. + The new style takes the formatting of the current paragraph. Нови стил преузима форматирање тренутног пасуса. + Theme Colors Боје теме + Theme Fonts Фонтови теме + Theme List Листа тема + Themes Теме + Thesaurus Речник синонима + Thick Дебело + Three Три + Tight Тесно + Top Border Горња ивица + Top of Page Врх стране + Top: Горе: + Total Row Ред укупно + Track Changes Праћење измена + Track Changes: On Праћење измена: укључено + Tracking Праћење + Translate Преведи + Transparency Транспарентност + Triangle Троугао + Two Два + Type a command, e.g. "insert table" Унесите наредбу, нпр. "уметни табелу" + Type Text Унеси текст + Underline Подвучено + Underline Words Only Подвуци само речи + Undo Опозови + Undo {action} Опозови {action} + Up Горе + Update Field Ажурирај поље + Update Index Ажурирај индекс + Update Style to Match Selection Ажурирај стил према избору + Update Table Ажурирај садржај + Update Table of Figures Ажурирај списак слика + Update to Match Ажурирај да одговара + Update to Match Selection Ажурирај према избору + UPPERCASE ВЕЛИКА СЛОВА + User Name Корисничко име + User name: Корисничко име: + Version History Историја верзија + Version {version} ({build}) Верзија {version} ({build}) + Vertical Усправно + Vertical Alignment Усправно поравнање + View Приказ + View Gridlines Прикажи линије мреже + View State Стање приказа + Views Прикази + Voice Глас + Watermark Водени жиг + Wave Таласасто + Web Layout Веб распоред + Web page (*.html) Веб страна (*.html) + Web\nLayout Веб\nраспоред + Whole page Цела страна + Whole words Само целе речи + Wide Широко + Widow/Orphan Control Контрола усамљених редова + Widow/Orphan control Контрола усамљених редова + Wildcards (regex) Џокер знаци (regex) + Window Прозор + Word Count Број речи + Word document (*.docx) Word документ (*.docx) + Word Left Реч лево + Word Right Реч десно + WordArt WordArt + WordCraft WordCraft + Words Речи + Words only Само речи + Wrap Text Преломи текст + Wrap\nText Преломи\nтекст + Write & Insert Fields Писање и уметање поља + Zoom Зумирање + Zoom In Увећај + Zoom Out Умањи + {group} settings Подешавања за {group} + {key}+Click to follow link {key}+клик за отварање везе + ➕ New comment ➕ Нови коментар + 📂 Browse… 📂 Потражи… + {words} words {words} речи + {selected} of {words} words {selected} од {words} речи + Direction: Смер: + Right-to-left Са десна на лево + Left-to-right Са лева на десно +# Zotero integration + Zotero Zotero + Citations Цитати + Document Документ + Add/Edit\nCitation Додај/измени\nцитат + Add\nNote Додај\nбелешку + Add/Edit\nBibliography Додај/измени\nбиблиографију + Refresh Освежи + Document\nPreferences Поставке\nдокумента + Unlink\nCitations Уклони везу\nса цитатима + Waiting for Zotero… Чека се Zotero… + Zotero: done Zotero: завршено + Zotero: cancelled Zotero: отказано + Yes Да + No Не + Move Past Citation Помери иза цитата + Move Past\nCitation Помери иза\nцитата + Next Sentence Следећа реченица + Previous Sentence Претходна реченица + Next sentence Следећа реченица + Previous sentence Претходна реченица + Play/Pause Пусти/паузирај + Read Aloud Status Статус читања наглас + Reading Speed Брзина читања + Skip Citations and Bibliography Прескочи цитате и библиографију + Skip citations & bibliography Прескочи цитате и библиографију + Stop Reading Заустави читање + Don't read Zotero, Mendeley or EndNote citations and bibliographies Не читај цитате и библиографију из Zotero, Mendeley или EndNote + Play Пусти + Pause Паузирај + Stop Заустави + Sentence Реченица + Paused Паузирано + Finished Завршено + Stopped Заустављено + Speed Брзина + Normal speed Нормална брзина + Your changes will be lost if you don't save them. Ваше измене ће бити изгубљене ако их не сачувате. + Scroll ribbon left Помери траку лево + Scroll ribbon right Помери траку десно +# Equations. + Change Structure Промени структуру + Change to Display Промени у приказну формулу + Change to Inline Промени у текстуалну формулу + Conversions Конверзије + Delete Back in Equation Обриши уназад у формули + Delete in Equation Обриши у формули + Edit Equation Уреди формулу + Enter in Equation Enter у формули + Equation Gallery Галерија формула + Equation Info Информације о формули + Equation Number Број формуле + Input Format Формат уноса + Insert Structure Уметни структуру + Insert Symbol Уметни симбол + Justification Поравнање + Leave Equation Изађи из формуле + Move in Equation Кретање у формули + Normal Text Обичан текст + Set Equation Постави формулу + Structure Actions Радње са структуром + Structures Структуре + Tools Алати + Type in Equation Унос у формули + Inline Текстуална + Number Број + Close Equation Затвори формулу + Insert New Equation Уметни нову формулу + Built-In Уграђено + Professional Професионални приказ + Linear Линеарни приказ + Fraction Разломак + Script Индекс + Radical Кореновање + Integral Интеграл + Large\nOperator Велики\nоператор + Bracket Заграда + Function Функција + Accent Знак акцента + Limit and\nLog Лимес и\nлогаритам + Operator Оператор + Equation Options Опције формуле + All symbol sets Сви скупови симбола + Send the document straight to the system print dialog, or save a copy in another format below. Шаље документ директно на системски дијалог за штампу, или сачувајте копију у другом формату испод. + Or save a copy in another format: Или сачувај копију у другом формату: + Opening print dialog… Отвара се дијалог за штампу… + Print failed: {error} Штампање неуспело: {error} + Couldn't open {path}: {error} Није могуће отворити {path}: {error} + More templates → Још шаблона → + No templates match your search. Ниједан шаблон не одговара претрази. + Search for templates Претражи шаблоне + Column Selection Избор колоне + 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: Налепи као: + The clipboard is empty. Остава је празна. + Formatted Text Форматирани текст + Rich Text Format (RTF) Обогаћени текст (RTF) + HTML Format HTML формат + Unformatted Text Неформатирани текст + Result Резултат + Keeps the fonts and formatting of the copied text. Задржава фонтове и форматирање копираног текста. + Reads the clipboard's rich text and keeps its formatting. Чита обогаћени текст (RTF) из оставе и задржава његово форматирање. + Reads the clipboard's web content and keeps its formatting. Чита веб садржај из оставе и задржава његово форматирање. + Drops all formatting and pastes plain text. Уклања све форматирање и лепи обичан текст. + Advanced Find Напредна претрага + Advanced Find… Напредна претрага… + Go To… Иди на… + Reading Highlight Истицање резултата + New Table Style Нови стил табеле + Modify Table Style Измени стил табеле + New Table Style… Нови стил табеле… + Modify Table Style… Измени стил табеле… + Apply formatting to: Примени форматирање на: + 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 Боја подвлачења + Interface theme: Тема интерфејса: + Interface\nTheme Тема\nинтерфејса + Interface Theme Тема интерфејса + Light Светла + Dark Тамна + Use system setting Према подешавању система + Letters Писма + E-mail Messages Поруке е-поште + Directory Именик + Normal Word Document Обичан Word документ + If…Then…Else… Ако…Онда…Иначе… + If…Then…Else Ако…Онда…Иначе + Merge Record # Број записа спајања + Skip Record If… Прескочи запис ако… + Skip Record If Прескочи запис ако + Equals: Једнако: + Then insert: Онда уметни: + Otherwise insert: Иначе уметни: + Courtesy Title Ословљавање + First Name Име + Last Name Презиме + Company Предузеће + Address Адреса + City Град + State Држава/покрајина + Postal Code Поштански број + Country Земља + (not matched) (није упарено) + Leave a recipient out of the merge when a field has this value. Изостави примаоца из спајања када поље има ову вредност. + Insert one text or another, depending on a field's value. Уметни један или други текст, зависно од вредности поља. + Select recipients first to match their fields. Прво изаберите примаоце да бисте упарили њихова поља. + The recipient-list field that fills each part of an address block and greeting line. Поље листе прималаца које попуњава сваки део блока адресе и реда поздрава. + No errors found: every merge field is in the recipient list. Нема грешака: свако поље за спајање постоји у листи прималаца. + These merge fields aren't in the recipient list: Ова поља за спајање нису у листи прималаца: + Add at least one field. Додајте бар једно поље. + Add Field Додај поље + Every field needs a name. Свако поље мора имати назив. + Field name Назив поља + Field names must be different. Називи поља морају бити различити. + Fields: Поља: + Field: Поље: + Find: Пронађи: + New Entry Нова ставка + Recipient List Листа прималаца + Recipients: {count} Прималаца: {count} + Remove field Уклони поље + Remove recipient Уклони примаоца + Select recipients first to choose from their fields. Прво изаберите примаоце да бисте бирали међу њиховим пољима. + Type a New List… Унеси нову листу… + Type at least one recipient. Унесите бар једног примаоца. + Type the recipients, or load them from a file. Унесите примаоце или их учитајте из фајла. + Use an Existing List… Користи постојећу листу… + A file dialog is already open. Дијалог за фајлове је већ отворен. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 33e766630..0aa2549f1 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -25,6 +25,11 @@ fn system_locales_map_to_languages() { assert_eq!(lang_from_tag("fr-FR"), None); assert_eq!(lang_from_tag(""), None); assert_eq!(lang_from_tag("_"), None); + assert_eq!(lang_from_tag("sr"), Some(lang("sr")), "Cyrillic is the default script"); + assert_eq!(lang_from_tag("sr-RS"), Some(lang("sr"))); + assert_eq!(lang_from_tag("sr-Cyrl-RS"), Some(lang("sr"))); + assert_eq!(lang_from_tag("sr-Latn-RS"), Some(lang("sr-latn"))); + assert_eq!(lang_from_tag("sr-Latn"), Some(lang("sr-latn"))); } #[test] @@ -209,6 +214,69 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() { } } +#[test] +fn serbian_covers_the_entire_existing_interface_catalog() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + let reference = keys(lang("zh-hans").0.source); + assert_eq!(keys(lang("sr").0.source), reference); + assert_eq!(keys(lang("sr-latn").0.source), reference); + let (cyr, lat) = (lang("sr"), lang("sr-latn")); + assert_eq!(tr(cyr, "Home"), "Почетак"); + assert_eq!(tr(lat, "Home"), "Početak"); + assert_eq!(tr(cyr, "unknown future label"), "unknown future label"); + assert_eq!(fmt(tr(lat, "Exported {path}"), &[("path", "draft-1.docx")]), "Izvezeno: draft-1.docx"); +} + +#[test] +fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("sr").0.source); + assert!(errors.is_empty()); + let chars: std::collections::HashSet = + entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}"); + } + } +} + +#[test] +fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("sr-latn").0.source); + assert!(errors.is_empty()); + // Serbian Latin's letters beyond plain ASCII: š č ć ž đ (and their capitals). + let extra = ['š', 'č', 'ć', 'ž', 'đ', 'Š', 'Č', 'Ć', 'Ž', 'Đ']; + let chars: std::collections::HashSet = + entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}"); + } + } +} + +#[test] +fn serbian_locales_resolve_by_script() { + let cyr = lang("sr"); + let lat = lang("sr-latn"); + for tag in ["sr", "sr-RS", "sr_RS.UTF-8", "SR", "sr-Cyrl", "sr-Cyrl-RS"] { + assert_eq!(lang_from_tag(tag), Some(cyr), "{tag}"); + } + for tag in ["sr-Latn", "sr-Latn-RS", "sr-latn-me"] { + assert_eq!(lang_from_tag(tag), Some(lat), "{tag}"); + } + assert_eq!(first_supported(["fr-FR", "sr-Latn-RS", "en-US"]), Some(lat)); + assert_eq!(tr(cyr, "File"), "Фајл"); + assert_eq!(tr(lat, "File"), "Fajl"); +} + #[test] fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_document() { let pt = lang("pt-br"); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f20d00d2c..9f9c8b328 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -917,6 +917,42 @@ Opening print dialog… Відкриття діалогу друку… Print failed: {error} Не вдалося надрукувати: {error} Couldn't open {path}: {error} Не вдалося відкрити {path}: {error} + Column Selection Виділення стовпця + New Table Style Новий стиль таблиці + Modify Table Style Змінити стиль таблиці + New Table Style… Новий стиль таблиці… + Modify Table Style… Змінити стиль таблиці… + Apply formatting to: Застосувати форматування до: + 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 Колір підкреслення Letters Листи E-mail Messages Повідомлення електронної пошти Directory Каталог @@ -970,39 +1006,3 @@ Dark Темна Use system setting Як у системі A file dialog is already open. Діалогове вікно файлу вже відкрито. - Column Selection Виділення стовпця - New Table Style Новий стиль таблиці - Modify Table Style Змінити стиль таблиці - New Table Style… Новий стиль таблиці… - Modify Table Style… Змінити стиль таблиці… - Apply formatting to: Застосувати форматування до: - 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 2d1b24a94..426a36a7e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -889,59 +889,6 @@ Opening print dialog… 正在打开打印对话框… Print failed: {error} 打印失败:{error} Couldn't open {path}: {error} 无法打开 {path}:{error} - Letters 信函 - E-mail Messages 电子邮件 - Directory 目录 - Normal Word Document 普通文档 - If…Then…Else… 如果…则…否则… - If…Then…Else 如果…则…否则 - Merge Record # 合并记录编号 - Skip Record If… 跳过记录条件… - Skip Record If 跳过记录条件 - Equals: 等于: - Then insert: 则插入: - Otherwise insert: 否则插入: - Courtesy Title 称谓 - First Name 名 - Last Name 姓 - Company 公司 - Address 地址 - City 城市 - State 省/州 - Postal Code 邮政编码 - Country 国家/地区 - (not matched) (未匹配) - Leave a recipient out of the merge when a field has this value. 当某个域为此值时,在合并中跳过该收件人。 - Insert one text or another, depending on a field's value. 根据域的值插入不同的文本。 - Select recipients first to match their fields. 请先选择收件人,才能匹配其域。 - The recipient-list field that fills each part of an address block and greeting line. 地址块和问候语的每一部分所使用的收件人列表域。 - No errors found: every merge field is in the recipient list. 未发现错误:所有合并域都在收件人列表中。 - These merge fields aren't in the recipient list: 以下合并域不在收件人列表中: - Add at least one field. 请至少添加一个域。 - Add Field 添加域 - Every field needs a name. 每个域都需要名称。 - Field name 域名称 - Field names must be different. 域名称不能重复。 - Fields: 域: - Field: 域: - Find: 查找: - New Entry 新建条目 - Recipient List 收件人列表 - Recipients: {count} 收件人:{count} - Remove field 删除域 - Remove recipient 删除收件人 - Select recipients first to choose from their fields. 请先选择收件人,才能从其域中选择。 - Type a New List… 键入新列表… - Type at least one recipient. 请至少键入一个收件人。 - Type the recipients, or load them from a file. 键入收件人,或从文件加载。 - Use an Existing List… 使用现有列表… - Interface theme: 界面主题: - Interface\nTheme 界面主题 - Interface Theme 界面主题 - Light 浅色 - Dark 深色 - Use system setting 使用系统设置 - A file dialog is already open. 已有一个文件对话框处于打开状态。 Column Selection 矩形选择 Style Inspector 样式检查器 Inspect Styles at Selection 检查所选内容的样式 @@ -1005,3 +952,56 @@ Space before 段前间距 Suppress line numbers 取消行号 Underline color 下划线颜色 + Letters 信函 + E-mail Messages 电子邮件 + Directory 目录 + Normal Word Document 普通文档 + If…Then…Else… 如果…则…否则… + If…Then…Else 如果…则…否则 + Merge Record # 合并记录编号 + Skip Record If… 跳过记录条件… + Skip Record If 跳过记录条件 + Equals: 等于: + Then insert: 则插入: + Otherwise insert: 否则插入: + Courtesy Title 称谓 + First Name 名 + Last Name 姓 + Company 公司 + Address 地址 + City 城市 + State 省/州 + Postal Code 邮政编码 + Country 国家/地区 + (not matched) (未匹配) + Leave a recipient out of the merge when a field has this value. 当某个域为此值时,在合并中跳过该收件人。 + Insert one text or another, depending on a field's value. 根据域的值插入不同的文本。 + Select recipients first to match their fields. 请先选择收件人,才能匹配其域。 + The recipient-list field that fills each part of an address block and greeting line. 地址块和问候语的每一部分所使用的收件人列表域。 + No errors found: every merge field is in the recipient list. 未发现错误:所有合并域都在收件人列表中。 + These merge fields aren't in the recipient list: 以下合并域不在收件人列表中: + Add at least one field. 请至少添加一个域。 + Add Field 添加域 + Every field needs a name. 每个域都需要名称。 + Field name 域名称 + Field names must be different. 域名称不能重复。 + Fields: 域: + Field: 域: + Find: 查找: + New Entry 新建条目 + Recipient List 收件人列表 + Recipients: {count} 收件人:{count} + Remove field 删除域 + Remove recipient 删除收件人 + Select recipients first to choose from their fields. 请先选择收件人,才能从其域中选择。 + Type a New List… 键入新列表… + Type at least one recipient. 请至少键入一个收件人。 + Type the recipients, or load them from a file. 键入收件人,或从文件加载。 + Use an Existing List… 使用现有列表… + Interface theme: 界面主题: + Interface\nTheme 界面主题 + Interface Theme 界面主题 + Light 浅色 + Dark 深色 + Use system setting 使用系统设置 + A file dialog is already open. 已有一个文件对话框处于打开状态。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 304acb69b..19cfd9dd3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -879,59 +879,6 @@ Opening print dialog… 正在開啟列印對話方塊… Print failed: {error} 列印失敗:{error} Couldn't open {path}: {error} 無法開啟 {path}:{error} - Letters 信函 - E-mail Messages 電子郵件 - Directory 目錄 - Normal Word Document 一般文件 - If…Then…Else… 如果…則…否則… - If…Then…Else 如果…則…否則 - Merge Record # 合併記錄編號 - Skip Record If… 略過記錄條件… - Skip Record If 略過記錄條件 - Equals: 等於: - Then insert: 則插入: - Otherwise insert: 否則插入: - Courtesy Title 稱謂 - First Name 名字 - Last Name 姓氏 - Company 公司 - Address 地址 - City 城市 - State 省/州 - Postal Code 郵遞區號 - Country 國家/地區 - (not matched) (未對應) - Leave a recipient out of the merge when a field has this value. 當某個欄位為此值時,在合併中略過該收件者。 - Insert one text or another, depending on a field's value. 依據欄位的值插入不同的文字。 - Select recipients first to match their fields. 請先選取收件者,才能對應其欄位。 - The recipient-list field that fills each part of an address block and greeting line. 地址區塊和問候語的每個部分所使用的收件者清單欄位。 - No errors found: every merge field is in the recipient list. 未發現錯誤:所有合併欄位都在收件者清單中。 - These merge fields aren't in the recipient list: 以下合併欄位不在收件者清單中: - Add at least one field. 請至少新增一個欄位。 - Add Field 新增欄位 - Every field needs a name. 每個欄位都需要名稱。 - Field name 欄位名稱 - Field names must be different. 欄位名稱不可重複。 - Fields: 欄位: - Field: 欄位: - Find: 尋找: - New Entry 新增項目 - Recipient List 收件者清單 - Recipients: {count} 收件者:{count} - Remove field 移除欄位 - Remove recipient 移除收件者 - Select recipients first to choose from their fields. 請先選取收件者,才能從其欄位中選擇。 - Type a New List… 輸入新清單… - Type at least one recipient. 請至少輸入一位收件者。 - Type the recipients, or load them from a file. 輸入收件者,或從檔案載入。 - Use an Existing List… 使用現有清單… - Interface theme: 介面主題: - Interface\nTheme 介面主題 - Interface Theme 介面主題 - Light 淺色 - Dark 深色 - Use system setting 使用系統設定 - A file dialog is already open. 已有一個檔案對話方塊開啟中。 Column Selection 矩形選取 Style Inspector 樣式檢查器 Inspect Styles at Selection 檢查選取範圍的樣式 @@ -995,3 +942,56 @@ Space before 段落前間距 Suppress line numbers 隱藏行號 Underline color 底線色彩 + Letters 信函 + E-mail Messages 電子郵件 + Directory 目錄 + Normal Word Document 一般文件 + If…Then…Else… 如果…則…否則… + If…Then…Else 如果…則…否則 + Merge Record # 合併記錄編號 + Skip Record If… 略過記錄條件… + Skip Record If 略過記錄條件 + Equals: 等於: + Then insert: 則插入: + Otherwise insert: 否則插入: + Courtesy Title 稱謂 + First Name 名字 + Last Name 姓氏 + Company 公司 + Address 地址 + City 城市 + State 省/州 + Postal Code 郵遞區號 + Country 國家/地區 + (not matched) (未對應) + Leave a recipient out of the merge when a field has this value. 當某個欄位為此值時,在合併中略過該收件者。 + Insert one text or another, depending on a field's value. 依據欄位的值插入不同的文字。 + Select recipients first to match their fields. 請先選取收件者,才能對應其欄位。 + The recipient-list field that fills each part of an address block and greeting line. 地址區塊和問候語的每個部分所使用的收件者清單欄位。 + No errors found: every merge field is in the recipient list. 未發現錯誤:所有合併欄位都在收件者清單中。 + These merge fields aren't in the recipient list: 以下合併欄位不在收件者清單中: + Add at least one field. 請至少新增一個欄位。 + Add Field 新增欄位 + Every field needs a name. 每個欄位都需要名稱。 + Field name 欄位名稱 + Field names must be different. 欄位名稱不可重複。 + Fields: 欄位: + Field: 欄位: + Find: 尋找: + New Entry 新增項目 + Recipient List 收件者清單 + Recipients: {count} 收件者:{count} + Remove field 移除欄位 + Remove recipient 移除收件者 + Select recipients first to choose from their fields. 請先選取收件者,才能從其欄位中選擇。 + Type a New List… 輸入新清單… + Type at least one recipient. 請至少輸入一位收件者。 + Type the recipients, or load them from a file. 輸入收件者,或從檔案載入。 + Use an Existing List… 使用現有清單… + Interface theme: 介面主題: + Interface\nTheme 介面主題 + Interface Theme 介面主題 + Light 淺色 + Dark 深色 + Use system setting 使用系統設定 + A file dialog is already open. 已有一個檔案對話方塊開啟中。 From f6a6ded2ccdb6bfbb980e7d0aceed06db3ad8965 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:55:28 +0200 Subject: [PATCH 13/16] Tests no longer depend on the installed fonts; PDF text of field results with ligatures (#261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Linux machine with Carlito and Noto Naskh Arabic installed, four layout and PDF tests failed on main: - field_results_are_extractable_text found a real bug: a field result is laid out as one cluster, and the display list gave each of its glyphs one character. A ligature (Carlito, the Calibri substitute, joins "ti") took only its first letter and shifted the rest, so the PDF text read "Sectio 3.01 fir details3". The layout now keeps each glyph's text range from shaping, so a ligature carries all its letters. A new test pins this with bundled Source Sans 3 ("fi", "ffi"), whatever fonts are installed. - fragmented_hidden_text_lays_out_like_the_visible_text compared visible text in alternating bold runs with an all-regular paragraph, so it only passed when the default font's bold happened to break lines the same. The plain paragraph now has the same bold runs. - persian_letters_join_and_brackets_mirror counted glyphs, but Noto Naskh Arabic draws a dotted letter as two glyphs (the dotless form and the dot). It now compares letters by shaping cluster. - glyph_runs_carry_their_text_for_pdf_export expected لا as one ligature glyph; Noto Naskh Arabic draws the lam and the alef separately. The ligature check now applies when the font forms the ligature. Co-authored-by: Claude Opus 5.5 --- crates/layout/src/display.rs | 30 ++++++++++++++++-------- crates/layout/src/para.rs | 21 +++++++++++++---- crates/layout/src/tests.rs | 11 ++++++--- crates/layout/src/tests_bidi.rs | 41 ++++++++++++++++++++++++--------- crates/pdf/src/tests.rs | 17 ++++++++++++++ 5 files changed, 91 insertions(+), 29 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index c9e19dcb3..801c0fbf4 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -343,26 +343,36 @@ fn lines( if matches!(c.kind, ClKind::Text | ClKind::Space) { let a = text.len(); // An object's cluster (a field, a note number) stands for the text it shows. - let mut shown = false; - if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _)| *i) - && let Some((_, s)) = pl.shown.get(i) + let mut shown = None; + if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i) + && let Some((_, s, r)) = pl.shown.get(i) { text.push_str(s); - shown = true; + shown = Some(r); } else if let Some(p) = para { text.push_str(p.text.get(c.start..c.end).unwrap_or("")); } let b = text.len(); let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]); - if shown && cg.len() > 1 { - // A whole field result is one cluster of many glyphs: give them a character - // each (the last takes any remainder), not the whole text as one ligature. + if let Some(shown) = shown + && cg.len() > 1 + { + // A whole field result is one cluster of many glyphs: each glyph gets the + // text it was shaped from (a ligature its letters), not the whole text as + // one ligature. Without that, a character each (the last takes any remainder). let bounds: Vec = text.get(a..b).unwrap_or("").char_indices().map(|(i, _)| a + i).collect(); + let known = shown.len() == cg.len(); for (n, g) in cg.iter().enumerate() { - let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a); - let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) }; + let r = match shown.get(n).filter(|_| known) { + Some(r) => (a + r.start).min(b)..(a + r.end).min(b), + None => { + let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a); + let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) }; + from..to + } + }; glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy)); - ranges.push(from..to.max(from)); + ranges.push(r.start..r.end.max(r.start)); } k += 1; continue; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 9d30c2240..995a04e5b 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -248,8 +248,9 @@ pub struct ParaLayout { /// tracked deletions in the final text. Resolved as laid out, table formatting included. pub left_out: Vec>, /// The text drawn by clusters that stand for an object (field results, note numbers, - /// equations) rather than for the paragraph's own text, by cluster index, sorted. - pub shown: Vec<(usize, String)>, + /// equations) rather than for the paragraph's own text, by cluster index, sorted, with each of + /// the cluster's glyphs' byte range in that text (a ligature's glyph spans its letters). + pub shown: Vec<(usize, String, Vec>)>, } /// What a table style gives the text of a cell: its paragraph and run formatting, with the @@ -302,7 +303,7 @@ struct Builder<'a> { /// Bidi level per byte of the paragraph text (empty: all left to right). levels: Vec, /// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`). - shown: Vec<(usize, String)>, + shown: Vec<(usize, String, Vec>)>, } /// What a piece of text is shaped as: one face, case, script formatting and direction. @@ -564,18 +565,28 @@ impl<'a> Builder<'a> { }); return; }; - // Merge into one cluster: rebase glyph dx onto the first cluster. + // Merge into one cluster: rebase glyph dx onto the first cluster, and keep each glyph's + // text (a ligature's glyph takes the letters of the clusters after it that have none). let mut x = 0.0; + let mut ranges: Vec> = Vec::new(); for c in &added { for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() { g.dx += x; } x += c.adv; + let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start)); + if c.g1 <= c.g0 { + let from = ranges.last().map(|r| r.start); + for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) { + r.end = b; + } + } + ranges.extend((c.g0..c.g1).map(|_| a..b)); } let g0 = first.g0; let style = first.style; let g1 = added.last().map(|c| c.g1).unwrap_or(g0); - self.shown.push((self.clusters.len(), text.to_string())); + self.shown.push((self.clusters.len(), text.to_string(), ranges)); self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false }); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index c84f1182a..ede55d3c1 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -260,10 +260,14 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() { #[test] fn fragmented_hidden_text_lays_out_like_the_visible_text() { // Visible words with a hidden multi-byte char after every visible one, formatted in alternating - // runs (bold on and off) so no two left-out runs are adjacent or merge. + // runs (bold on and off) so no two left-out runs are adjacent or merge. The plain paragraph has + // the same visible runs without the hidden chars, so both lay out the same widths whatever font + // the default font resolves to. let words = "Hyphenation wraps international words across narrow columns of text ".repeat(6); let mut d = Document::from_text(""); d.settings.auto_hyphenation = true; + let mut plain = Document::from_text(""); + plain.settings.auto_hyphenation = true; let mut text = String::new(); let mut kept = Vec::new(); for (i, c) in words.chars().enumerate() { @@ -276,10 +280,11 @@ fn fragmented_hidden_text_lays_out_like_the_visible_text() { d.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap(); let hidden = wordcraft_doc::CharProps { hidden: Some(true), bold: Some(i % 2 == 1), ..Default::default() }; d.insert_text(&Pos::body(0, at), "é", &hidden).unwrap(); + let n = plain.para(StoryRef::Body, &Path::top(0)).unwrap().len(); + plain.insert_text(&Pos::body(0, n), &c.to_string(), &bold).unwrap(); } assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, text); - let mut plain = Document::from_text(&words); - plain.settings.auto_hyphenation = true; + assert_eq!(plain.para(StoryRef::Body, &Path::top(0)).unwrap().text, words); for doc in [&mut d, &mut plain] { doc.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_right = Some(468.0 - 90.0)).unwrap(); } diff --git a/crates/layout/src/tests_bidi.rs b/crates/layout/src/tests_bidi.rs index 6a3545aa4..da738e535 100644 --- a/crates/layout/src/tests_bidi.rs +++ b/crates/layout/src/tests_bidi.rs @@ -303,24 +303,38 @@ fn arabic_face() -> Option { wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ')) } +/// The glyph ids of each cluster, in order. A font may draw a letter as several glyphs (Noto +/// Naskh Arabic draws ب as its dotless form plus the dot), so letters are compared by cluster. +fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec> { + let mut out: Vec<(usize, Vec)> = Vec::new(); + for g in glyphs { + match out.last_mut() { + Some((c, ids)) if *c == g.cluster => ids.push(g.gid), + _ => out.push((g.cluster, vec![g.gid])), + } + } + out.into_iter().map(|(_, ids)| ids).collect() +} + #[test] fn persian_letters_join_and_brackets_mirror() { let Some(face) = arabic_face() else { eprintln!("skipped: no font with Persian letters installed"); return; }; - let isolated = wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true); - let joined = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true); - assert_eq!(joined.len(), 3); - assert!(joined.iter().all(|g| g.gid != 0), "the font has the letters"); + let isolated = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true)); + assert_eq!(isolated.len(), 1, "{isolated:?}"); + let shaped = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true); + let joined = cluster_glyphs(&shaped); + assert_eq!(joined.len(), 3, "one cluster per letter: {joined:?}"); + assert!(shaped.iter().all(|g| g.gid != 0), "the font has the letters"); // Initial, medial and final forms differ from the isolated letter. - assert!(joined.iter().any(|g| g.gid != isolated[0].gid), "cursive joining picks contextual forms"); + assert!(joined.iter().any(|g| *g != isolated[0]), "cursive joining picks contextual forms: {joined:?} {isolated:?}"); // Clusters come back in logical order. - assert!(joined.windows(2).all(|w| w[0].cluster <= w[1].cluster)); + assert!(shaped.windows(2).all(|w| w[0].cluster <= w[1].cluster)); // ZWNJ (نیم‌فاصله) breaks the join: "می‌خواهم" keeps می separate. - let with_zwnj = wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true); - let first = with_zwnj.first().map(|g| g.gid); - assert_eq!(first, Some(isolated[0].gid), "a letter before ZWNJ takes its isolated form"); + let with_zwnj = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true)); + assert_eq!(with_zwnj.first(), isolated.first(), "a letter before ZWNJ takes its isolated form"); // In right-to-left text, "(" is drawn with the mirrored glyph. let open = wordcraft_fonts::shape_run(&face, "(", &[], |c| c, true); let close = wordcraft_fonts::shape_run(&face, ")", &[], |c| c, false); @@ -354,6 +368,10 @@ fn glyph_runs_carry_their_text_for_pdf_export() { let s = "سلام WordCraft می‌خواهم"; let d = doc_of(&[(s, true)]); let l = lay(&d); + let lam_alef_ligature = arabic_face().is_some_and(|f| { + let g = wordcraft_fonts::shape_run(&f, "لا", &[], |c| c, true); + !g.is_empty() && g.iter().all(|g| g.cluster == 0) + }); let mut all = String::new(); for it in display::page_display(&d, &l.pages[0], &Default::default()) { if let display::Draw::Glyphs { glyphs, text, ranges, .. } = it { @@ -361,8 +379,9 @@ fn glyph_runs_carry_their_text_for_pdf_export() { for r in &ranges { assert!(text.get(r.clone()).is_some_and(|t| !t.is_empty()), "{r:?} in {text:?}"); } - // A ligature glyph (لا) shows both its letters (when a Persian font draws it). - for r in ranges.iter().filter(|_| arabic_face().is_some()) { + // A ligature glyph (لا) shows both its letters (when the Persian font draws one: some, + // such as Noto Naskh Arabic, draw the lam and the alef as glyphs of their own). + for r in ranges.iter().filter(|_| lam_alef_ligature) { if text.get(r.clone()) == Some("ل") { panic!("the lam of لا is drawn as one glyph with the alef: {text:?} {ranges:?}"); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9c5fdfbfe..bd1c2be3a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -539,6 +539,23 @@ fn field_results_are_extractable_text() { assert!(squash(&text).contains("See Section 3.01 for details."), "{text:?}"); } +#[test] +fn field_result_ligatures_extract_all_their_letters() { + // A field result is shaped as one cluster; a ligature in it (Carlito, the Calibri substitute, + // joins "ti") used to get only its first letter, shifting the rest: "Sectio 3.01". Bundled + // Source Sans 3 joins "fi" and "ffi", so this doesn't depend on the installed fonts. + let font = CharProps { font: Some("Source Sans 3".into()), ..Default::default() }; + let result = "the first office"; + let face = wordcraft_fonts::FontDb::global().face("Source Sans 3", "Regular"); + assert!(wordcraft_fonts::shape(&face, result, &[], |c| c).len() < result.chars().count(), "the result has ligatures"); + let mut d = Document::new(); + let mut p = Paragraph::with_text("See here.", font.clone()); + p.insert_object(4, InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false }, &font).unwrap(); + d.body = vec![para_block(p)]; + let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat(); + assert!(squash(&text).contains("See the first office here."), "{text:?}"); +} + #[test] fn synthetic_bold_text_is_extracted_once() { // JetBrains Mono ships without a bold face, so its bold is filled and stroked: the text From da18ae893332e657f98593bd3cfcdcf5e132561b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:55:50 +0200 Subject: [PATCH 14/16] =?UTF-8?q?File=20=E2=80=BA=20Info:=20Title,=20Autho?= =?UTF-8?q?r=20and=20the=20other=20property=20fields=20keep=20what=20you?= =?UTF-8?q?=20type,=20and=20browser=20saves=20advance=20the=20save=20stamp?= =?UTF-8?q?s=20(#260,=20#262)=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Info page edited a copy of the document properties that was re-read from the document every frame and only written back when a field lost focus, so each keystroke vanished on the next frame and nothing was ever committed. The fields now commit through file.properties as you type (undoable, one step per visit to a field, marks the document dirty). The web build's Save writes the bytes itself instead of running file.save, so it never stamped the modified time, last-modified-by and revision. Both paths now share Session::stamp_save. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 7 +-- crates/engine/src/session.rs | 12 +++++ crates/ui-egui/src/backstage.rs | 90 +++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 9 ++++ 4 files changed, 107 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d11dae6ae..2c8c3ee80 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -131,12 +131,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { return Ok(json!({"saved": false})); } }; - s.doc.core.modified = super::now_iso(); - if s.doc.core.created.is_empty() { - s.doc.core.created = s.doc.core.modified.clone(); - } - s.doc.core.last_modified_by = s.author.clone(); - s.doc.core.revision = s.doc.core.revision.saturating_add(1); + s.stamp_save(); crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?; let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 318dad034..b91495519 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -413,6 +413,18 @@ impl Session { pub fn join_next_undo(&mut self) { self.join_next = true; } + /// Stamp the document's save metadata (modified time, last modified by, revision; created + /// when it has none) the way Word does on every save. `file.save` calls it, and so does any + /// front end that writes the bytes itself (the web build's download, #262). + pub fn stamp_save(&mut self) { + let core = &mut self.doc.core; + core.modified = crate::cmd::now_iso(); + if core.created.is_empty() { + core.created = core.modified.clone(); + } + core.last_modified_by = self.author.clone(); + core.revision = core.revision.saturating_add(1); + } /// Close an open typing group (caret moved, other command). pub fn close_typing(&mut self) { self.typing_open = false; diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 155e77b65..80d3f777b 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -270,17 +270,35 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) { ui.columns(2, |cols| { let ui = &mut cols[0]; ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0))); + // The fields edit a copy of the document's properties that is read afresh every frame, + // so each change goes into the document as it is typed (#260): waiting for the field to + // lose focus dropped the keystrokes, and leaving File with Escape never loses it at all. + // The keystrokes of one visit to a field are a single undo step. let mut props = app.session.doc.core.clone(); - let mut changed = false; + let mut edited = None; + let mut focused = None; egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] { - ui.label(tl!(l)); - changed |= ui.text_edit_singleline(v).lost_focus(); + let label = ui.label(tl!(l)); + let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id); + if r.changed() { + edited = Some(l); + } + if r.has_focus() { + focused = Some(l); + } ui.end_row(); } }); - if changed { - let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})); + if let Some(field) = edited { + if app.info_editing == Some((field, app.session.rev())) { + app.session.join_next_undo(); + } + let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok(); + app.info_editing = ok.then(|| (field, app.session.rev())); + } + if focused != app.info_editing.map(|(f, _)| f) { + app.info_editing = None; } let ui = &mut cols[1]; ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0))); @@ -445,4 +463,66 @@ mod tests { assert!(h.query_by_label("Report").is_some(), "New shows every template"); assert!(h.query_by_label("Recent").is_none(), "New has no Recent list"); } + + /// #260: File › Info's Title and Author fields dropped every keystroke (they edited a copy + /// re-read each frame and only wrote it back when the field lost focus), so a save had no + /// title or author. Typing now lands in the document as it happens, one undo step per field, + /// and the browser's Save downloads it with the save stamps advanced (#262). + #[test] + fn info_fields_keep_typed_properties_through_a_web_save() { + use egui::accesskit::Role; + let got: std::rc::Rc)>>> = Default::default(); + let sink = got.clone(); + let services = Services { + download: Some(Box::new(move |n: &str, b: &[u8]| { + sink.borrow_mut().push((n.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("XYZ")), services); + a.session.author = "GAMMA".into(); + a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + for (field, text) in [("Title", "ALPHA"), ("Author", "BETA")] { + h.get_by_role_and_label(Role::TextInput, field).click(); + h.run_steps(2); + for c in text.chars() { + h.get_by_role_and_label(Role::TextInput, field).type_text(&c.to_string()); + h.run_steps(2); + } + assert_eq!(h.get_by_role_and_label(Role::TextInput, field).value().as_deref(), Some(text), "{field} shows what was typed"); + } + let core = h.state().session.doc.core.clone(); + assert_eq!((core.title.as_str(), core.creator.as_str()), ("ALPHA", "BETA")); + assert!(h.state().session.dirty, "the property edits are unsaved changes"); + + // Save (web: a download) writes them into docProps/core.xml, with fresh save stamps. + let a = h.state_mut(); + let created = a.session.doc.core.created.clone(); + a.run("file.save", json!({})).unwrap(); + a.run("file.save", json!({})).unwrap(); + let (name, bytes) = got.borrow_mut().pop().unwrap(); + let back = wordcraft_engine::io::open_bytes(&name, &bytes).unwrap(); + assert_eq!((back.core.title.as_str(), back.core.creator.as_str()), ("ALPHA", "BETA")); + assert_eq!(back.core.last_modified_by, "GAMMA"); + assert_eq!(back.core.revision, core.revision + 2, "each save advances the revision"); + assert!(created.is_empty() && !back.core.created.is_empty(), "the first save sets the creation time"); + assert!(!back.core.modified.is_empty()); + assert!(!a.session.dirty); + + // Each field's typing is one undo step. + a.session.run("edit.undo", &json!({})).unwrap(); + assert_eq!((a.session.doc.core.title.as_str(), a.session.doc.core.creator.as_str()), ("ALPHA", "")); + a.session.run("edit.undo", &json!({})).unwrap(); + assert_eq!(a.session.doc.core.title, ""); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb9220da6..181dcaee4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -193,6 +193,10 @@ pub struct WordApp { autosave_path: Option, /// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]). file_dialog: Option, + /// File › Info: the property field being typed in and the document revision after its last + /// keystroke, so one visit's keystrokes make a single undo step (and anything else in + /// between, such as an Undo, starts a new one). + pub(crate) info_editing: Option<(&'static str, u64)>, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -252,6 +256,7 @@ impl WordApp { change_picture_target: None, recipient_list_pending: false, file_dialog: None, + info_editing: None, } } @@ -342,6 +347,10 @@ impl WordApp { if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") { let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name()); let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name }; + // A save is a save, download or not: advance the revision and modified stamp (#262). + if matches!(id, "file.save" | "file.saveAs") { + self.session.stamp_save(); + } let bytes = wordcraft_engine::io::save_bytes(&name, &self.session.doc)?; if let Some(d) = &self.services.download && let Err(e) = d(&name, &bytes) From a99dddcbdc18dc51aaa77c94a7ab638e9f43b07f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:56:32 +0200 Subject: [PATCH 15/16] =?UTF-8?q?Show/Hide=20=C2=B6:=20paragraph=20mark=20?= =?UTF-8?q?no=20longer=20overlaps=20text=20in=20mixed-direction=20paragrap?= =?UTF-8?q?hs=20(#278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Show section breaks with Show/Hide ¶ (#42) With formatting marks on, the last paragraph of a section now ends in a dotted double rule with the break's name centred in it (Section Break (Next Page), (Continuous), (Even Page), (Odd Page)) instead of a plain ¶. The type shown is the start type of the section that follows. The mark is screen-only: PDF export skips it, and print/PNG never turn marks on. Co-Authored-By: Claude Opus 5.5 * Show/Hide ¶: put the paragraph mark past mixed-direction text (#274) The paragraph mark (and the section-break mark) was anchored at the caret position after the paragraph's logically last character. When that text runs against the paragraph's direction (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one) that position is inside the line, so the mark was drawn on top of a letter. The mark now sits at the line's visual end for the paragraph's direction: right of the rightmost glyph in a left-to-right paragraph, left of the leftmost in a right-to-left one. Pure left-to-right and pure right-to-left lines are unchanged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/layout/src/display.rs | 79 ++++++++++++++++++++++++++++++++++-- crates/layout/src/para.rs | 8 ++++ crates/layout/src/tests.rs | 57 ++++++++++++++++++++++++++ crates/pdf/src/lib.rs | 2 +- crates/render/src/lib.rs | 19 +++++++++ 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 801c0fbf4..8cdf8ee53 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -2,7 +2,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind}; use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline}; -use wordcraft_doc::{Document, Path, StoryRef}; +use wordcraft_doc::section::SectionStart; +use wordcraft_doc::{Block, Document, Path, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; @@ -82,6 +83,14 @@ pub enum Draw { turn: TextDirection, items: Vec, }, + /// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at + /// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark. + MarkText { + x: f32, + baseline: f32, + size: f32, + text: String, + }, } impl Draw { @@ -568,7 +577,9 @@ fn lines( } } if line.end == LineEnd::Para { - let ex = x + line.end_x(); + // The mark follows the line's visual end, past every glyph, even where the + // logically last text runs against the paragraph's direction. + let ex = x + line.visual_end_x(); let size = pl.lines.first().map(|_| msize).unwrap_or(msize); // A right-to-left paragraph's mark sits at its end, on the left. let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 }; @@ -577,13 +588,75 @@ fn lines( .and_then(|p| p.mark.ins.or(p.mark.del)) .filter(|_| opts.markup) .map(|r| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))); - out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color }); + // The last paragraph of a section ends in a section break instead of a plain ¶. + if let Some(start) = section_break_after(doc, story, path) { + let (x0, x1) = if line.rtl { (x + line.left, ex - 2.0) } else { (ex + 2.0, x + line.right) }; + section_break_mark(start, x0, x1, base, size, alpha, out); + } else { + out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶', color }); + } } } let _ = bottom; } } +/// The kind of section break that ends at body paragraph `path`: the start type of the section +/// that follows it. `None` for any other paragraph, and for the document's last section. +fn section_break_after(doc: &Document, story: StoryRef, path: &Path) -> Option { + if story != StoryRef::Body || path.0.len() != 1 { + return None; + } + let i = *path.0.first()? as usize; + match doc.body.get(i).map(|b| &**b) { + Some(Block::Para(p)) if p.section.is_some() => {} + _ => return None, + } + let secs = doc.sections(); + let k = secs.iter().position(|(end, _)| *end == i)?; + secs.get(k + 1).map(|(_, s)| s.start) +} + +/// On-screen name of a section break. +pub fn section_break_label(start: SectionStart) -> &'static str { + match start { + SectionStart::NextPage => "Section Break (Next Page)", + SectionStart::Continuous => "Section Break (Continuous)", + SectionStart::EvenPage => "Section Break (Even Page)", + SectionStart::OddPage => "Section Break (Odd Page)", + SectionStart::NextColumn => "Section Break (Next Column)", + } +} + +/// Width of a [`Draw::MarkText`] label. +pub fn mark_text_width(text: &str, size: f32) -> f32 { + let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face; + let k = size / face.upem.max(1.0) as f32; + text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum() +} + +/// A section break mark between `x0` and `x1`: a dotted double rule with the break's name centred +/// in it (just the name when there is no room for the rule). +fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f32, alpha: f32, out: &mut Vec) { + let text = section_break_label(start); + let lsize = (size * 0.8).clamp(6.0, 11.0); + let w = mark_text_width(text, lsize); + let gap = lsize * 0.4; + let mid = base - size * 0.3; + let color = Rgb(0x60, 0x60, 0x60); + if x1 - x0 <= w + 4.0 * gap { + out.push(Draw::MarkText { x: x0, baseline: base, size: lsize, text: text.into() }); + return; + } + let tx = (x0 + x1 - w) / 2.0; + for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] { + for y in [mid - 1.2, mid + 1.2] { + out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha }); + } + } + out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() }); +} + /// An equation's glyphs and rules with its origin at (`x`, `base`). fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec) { for it in items { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 995a04e5b..171b138cf 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -168,6 +168,14 @@ impl Line { None => self.xs.last().copied().unwrap_or(self.left), } } + /// x of the line's visual end for the paragraph's direction, where its paragraph mark goes: + /// the right edge of the rightmost cluster in a left-to-right paragraph, the left edge of the + /// leftmost in a right-to-left one. Differs from [`Line::end_x`] when the logically last text + /// runs the other way (Hebrew ending a left-to-right paragraph, Latin ending a right-to-left one). + pub fn visual_end_x(&self) -> f32 { + let end = self.end_x(); + if self.rtl { self.vis.iter().map(|v| v.x).fold(end, f32::min) } else { self.vis.iter().map(|v| v.x + v.w).fold(end, f32::max) } + } /// x where the hyphen of a hyphenated line (advance `adv`) is drawn. pub fn hyphen_x(&self, adv: f32) -> f32 { match self.vis.last() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ede55d3c1..ad198c519 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -703,6 +703,63 @@ fn a_tracked_paragraph_mark_shows_in_its_authors_colour() { assert_eq!(marks(false), [None, None]); } +#[test] +fn section_breaks_show_with_formatting_marks() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + // Two sections: "One" ends the first; the second starts as `start`. + let doc = |start: SectionStart| { + let mut d = Document::from_text("One\nTwo"); + d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default())); + d.last_section.start = start; + d + }; + let labels = |d: &Document, marks: bool| -> (Vec, usize) { + let l = lay(d); + let items = display::page_display(d, &l.pages[0], &display::DisplayOptions { marks, ..Default::default() }); + let texts = items.iter().filter_map(|i| if let display::Draw::MarkText { text, .. } = i { Some(text.clone()) } else { None }).collect(); + let pilcrows = items.iter().filter(|i| matches!(i, display::Draw::Mark { ch: '¶', .. })).count(); + (texts, pilcrows) + }; + let d = doc(SectionStart::Continuous); + // The break replaces the first paragraph's ¶; the last paragraph keeps its own. + assert_eq!(labels(&d, true), (vec!["Section Break (Continuous)".to_string()], 1)); + assert_eq!(labels(&d, false), (vec![], 0)); + assert_eq!(labels(&doc(SectionStart::NextPage), true).0, ["Section Break (Next Page)"]); + assert_eq!(labels(&doc(SectionStart::EvenPage), true).0, ["Section Break (Even Page)"]); + assert_eq!(labels(&doc(SectionStart::OddPage), true).0, ["Section Break (Odd Page)"]); + // A document with one section has no break. + assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2)); +} + +#[test] +fn paragraph_marks_sit_past_text_running_the_other_way() { + // A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the + // logically last letters are at the wrong edge, but the ¶ still goes past every glyph. + let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc"); + let at = Pos::body(1, 0); + d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap(); + let l = lay(&d); + // Leftmost and rightmost glyph edges (page x) of each paragraph's single line. + let extents: Vec<(f32, f32)> = l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para, *x)) } else { None }) + .map(|(pl, x)| { + let ln = &pl.lines[0]; + let edges = (ln.c0..ln.c1).filter_map(|k| Some((ln.cl_left(k)?, ln.cl_right(k)?))); + edges.fold((f32::MAX, f32::MIN), |(a, b), (l, r)| (a.min(x + l), b.max(x + r))) + }) + .collect(); + let marks: Vec<(f32, f32)> = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() }) + .into_iter() + .filter_map(|i| if let display::Draw::Mark { ch: '¶', x, size, .. } = i { Some((x, size)) } else { None }) + .collect(); + assert_eq!((extents.len(), marks.len()), (2, 2)); + let ((ltr_x, _), (rtl_x, size)) = (marks[0], marks[1]); + assert!(ltr_x >= extents[0].1, "LTR ¶ at {ltr_x} should be right of all glyphs {:?}", extents[0]); + assert!(rtl_x + size * 0.5 <= extents[1].0, "RTL ¶ at {rtl_x} should be left of all glyphs {:?}", extents[1]); +} + fn border_lines(d: &Document) -> Vec<(f32, f32, f32, f32)> { border_lines_in(d, &lay(d)) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3a79b9262..b191412b9 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -650,7 +650,7 @@ impl Exporter<'_> { s.set_fill(None); s.set_stroke(None); } - Draw::Mark { .. } => {} + Draw::Mark { .. } | Draw::MarkText { .. } => {} Draw::Turned { x, y, turn, items } => { if !ok(*x) || !ok(*y) { return; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ac1179395..de4d47239 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -382,6 +382,25 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k)); ctx.fill_path(&o); } + Draw::MarkText { x, baseline, size, text } => { + let base_face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face; + ctx.set_paint(color(opts.ink(opts.mark_color), 0.9)); + let mut gx = *x as f64; + for ch in text.chars() { + let mut face = base_face; + if face.glyph_for(ch) == 0 + && let Some(f) = FontDb::global().fallback_for(ch, face.id()) + { + face = wordcraft_fonts::FaceRef::of(&f); + } + let gid = face.glyph_for(ch); + let k = *size as f64 / face.upem.max(1.0); + let o = FontDb::global().outline(&face, gid); + ctx.set_transform(view * Affine::translate((gx, *baseline as f64)) * Affine::scale(k)); + ctx.fill_path(&o); + gx += face.advance(gid) * k; + } + } Draw::Image { rect, media, crop, alpha } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.overlaps(*visible) { From d925423d66cf2a3ab73c480723d3580546e530dd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:07:48 -0400 Subject: [PATCH 16/16] Table styles: Table Grid and Normal Table can't be deleted even when a .docx marks them custom A .docx can set w:customStyle on any style, which made Word's built-in table styles look custom and deletable. table.deleteStyle and the ribbon's Delete Table Style now also recognise them by id and name, so no table is left on a style that's gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/table_style.rs | 11 ++++++++++- crates/engine/src/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/ribbon.rs | 7 ++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs index 11beed7ca..ce61d9f03 100644 --- a/crates/engine/src/cmd/table_style.rs +++ b/crates/engine/src/cmd/table_style.rs @@ -163,7 +163,7 @@ pub fn preview(s: &Session, v: &Value) -> Option { fn delete_style(s: &mut Session, v: &Value) -> CmdResult { let id = target(s, v)?; let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?; - if st.builtin { + if is_builtin_table_style(&st) { return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name))); } let mut rebased = Vec::new(); @@ -194,6 +194,15 @@ fn delete_style(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"deleted": id, "tables": tables, "rebased": rebased})) } +/// Whether `st` is one of Word's built-in table styles. A .docx can mark any style custom +/// (`w:customStyle`), so Table Grid and Normal Table are recognised by id and name too: deleting +/// them would leave new tables (and the fallback for deleted styles) without a style to take. +pub fn is_builtin_table_style(st: &Style) -> bool { + const IDS: [&str; 2] = ["TableGrid", "TableNormal"]; + const NAMES: [&str; 3] = ["Table Grid", "Normal Table", "Table Normal"]; + st.builtin || IDS.iter().any(|x| st.id.eq_ignore_ascii_case(x)) || NAMES.iter().any(|x| st.name.eq_ignore_ascii_case(x)) +} + /// Tables nest at most this deep for restyling. const MAX_DEPTH: usize = 32; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 62cd3e46d..8790bd345 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2266,3 +2266,20 @@ fn delete_table_style_falls_back_and_undoes() { assert_eq!(style(&s).as_deref(), Some("Base")); assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base")); } + +/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so +/// no table is left pointing at a style that's gone. +#[test] +fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + for st in s.doc.styles.styles.iter_mut() { + st.builtin = false; + } + for name in ["Table Grid", "Normal Table", "TableGrid"] { + assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}"); + } + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap(); + assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists"); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index bdb354d24..e8fe936dc 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1267,7 +1267,12 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { let current = look.and_then(|_| { let (tp, _, _) = app.session.sel.focus.path.cell()?; let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?; - app.session.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.builtin) + app.session + .doc + .styles + .get(&id) + .filter(|st| st.kind == wordcraft_doc::StyleKind::Table) + .map(wordcraft_engine::cmd::table_style::is_builtin_table_style) }); let can_modify = current.is_some(); // Built-in table styles can't be deleted.