diff --git a/ROADMAP.md b/ROADMAP.md index e4303db56e..6881540297 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,7 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| -| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44); v0.4.0 released | +| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176); v0.4.0 released | | 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) | | 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) | | 2026-10-07 | v0.1.0 released; contributor credits in About (#6) | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 80d3f777b4..0842063782 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -257,7 +257,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { fn open_page(app: &mut WordApp, ui: &mut Ui) { heading(ui, "Open"); - if ui.button(egui::RichText::new(tl!("📂 Browse…")).font(medium(14.0))).clicked() { + if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() { let _ = app.run("ui.openFileDialog", json!({})); } ui.add_space(18.0); @@ -357,7 +357,16 @@ fn options_page(app: &mut WordApp, ui: &mut Ui) { } }); theme_picker(app, ui); - ui.checkbox(&mut app.autosave, tl!("AutoSave documents you have saved in WordCraft")); + // The browser can't write to the user's files, so AutoSave can't be turned on there (#176). + let browser = app.autosave_block() == Some(crate::AutoSaveBlock::Browser); + let mut autosave = app.autosave && !browser; + let r = ui.add_enabled(!browser, egui::Checkbox::new(&mut autosave, tl!("AutoSave documents you have saved in WordCraft"))); + if browser { + r.on_disabled_hover_text(crate::AutoSaveBlock::Browser.reason()); + } else if r.changed() { + app.autosave = autosave; + app.session.autosave = autosave; + } let mut dark_page = app.session.view.dark_mode; if ui .checkbox(&mut dark_page, tl!("Dark page (white text on black)")) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 4d993184cd..e4be4aa942 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -63,26 +63,34 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let mut right_start = full.max.x; ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - // AutoSave toggle (on once the document has been saved in this session). - let saved_here = app.saved_here(); - ui.label(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(t.text_dim)); - let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); + // AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't + // cover the document (#196, #176); clicking it then offers Save As where that helps. + let block = app.autosave_block(); let on = app.autosaves(); - ui.painter().rect( - r, - 8.0, - if on { t.accent } else { t.input }, - Stroke::new(1.0, if on { t.accent } else { t.border_strong }), - egui::StrokeKind::Inside, - ); + let label = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim }; + ui.label(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(label)); + let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); + let (fill, stroke, knob_fill) = match (&block, on) { + (Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)), + (None, true) => (t.accent, t.accent, egui::Color32::WHITE), + (None, false) => (t.input, t.border_strong, t.text_dim), + }; + ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside); let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 }; - ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, if on { egui::Color32::WHITE } else { t.text_dim }); - if resp.on_hover_text(tl!("AutoSave saves every change to the file (needs a saved document)")).clicked() { - if saved_here { - app.toggle_autosave(); - } else { + ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill); + let tip = block + .as_ref() + .map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason()); + if resp.on_hover_text(tip).clicked() { + match block { + None => { + let _ = app.run("file.autosave", json!({})); + } // AutoSave comes on once Save As has saved (maybe on a later frame, #94). - let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave); + Some(b) if b.save_as_helps() => { + let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave); + } + Some(b) => app.status(b.reason()), } } ui.add_space(8.0); @@ -322,9 +330,6 @@ fn small_icon(ui: &mut Ui, icon: &str, tip: &str) -> bool { } impl WordApp { - pub fn toggle_autosave(&mut self) { - self.autosave = !self.autosave; - } /// Word count, recomputed only when the document changes. pub fn cached_word_count(&mut self) -> usize { // The count option changes the count without a document change. diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 7b2d144a69..0720afeda1 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -210,7 +210,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) { if ui.add_enabled(start + page < all.len(), egui::Button::new("▼").min_size(vec2(16.0, 22.0))).clicked() { ui.data_mut(|d| d.insert_temp(start_id, start + per_row)); } - let resp = ui.add(egui::Button::new("⋯").min_size(vec2(16.0, 22.0))).on_hover_text(tl!("All symbol sets")); + let resp = ui.add(egui::Button::new("…").min_size(vec2(16.0, 22.0))).on_hover_text(tl!("All symbol sets")); egui::Popup::menu(&resp).show(|ui| { ui.set_width(420.0); ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 97454e632a..81ef1b88ff 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -67,6 +67,10 @@ AutoFit Window Ajustar a la ventana AutoSave Autoguardado AutoSave saves every change to the file (needs a saved document) El autoguardado guarda cada cambio en el archivo (requiere un documento guardado) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. El autoguardado no está disponible en el navegador: no puede escribir en sus archivos. Guardar descarga una copia. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. El autoguardado solo mantiene archivos .docx, .odt y .rtf; {name} no puede conservar todo el formato. Guarde como .docx para activar el autoguardado. + Save the document to turn on AutoSave Guarde el documento para activar el autoguardado + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Guarde el documento para activar el autoguardado (un archivo que solo ha abierto no se reescribe hasta que lo guarde) AutoText Texto automático Automatic Automático Automatic ({language}) Automático ({language}) @@ -838,8 +842,7 @@ {key}+Click to follow link {key}+clic para seguir el vínculo {selected} of {words} words {selected} de {words} palabras {words} words {words} palabras - ➕ New comment ➕ Nuevo comentario - 📂 Browse… 📂 Examinar… + Browse… Examinar… Equation Ecuación Review Revisar Track\nChanges Control de\ncambios diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a75ef76076..69c038908f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -73,6 +73,10 @@ Search for templates テンプレートを検索 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) 自動保存は変更のたびにファイルへ保存します(保存済みの文書が必要です) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. ブラウザーでは自動保存を使えません(ファイルに書き込めないため)。保存するとコピーがダウンロードされます。 + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. 自動保存の対象は .docx、.odt、.rtf ファイルだけです。{name} ではすべての書式を保持できません。自動保存をオンにするには .docx として保存してください。 + Save the document to turn on AutoSave 自動保存をオンにするには文書を保存してください + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) 自動保存をオンにするには文書を保存してください(開いただけのファイルは、保存するまで書き換えられません) AutoText 定型句 Backspace Backspace Banded Columns 縞模様(列) @@ -810,8 +814,7 @@ Zoom Out 縮小 {group} settings {group} の設定 {key}+Click to follow link {key} キーを押しながらクリックしてリンク先を表示 - ➕ New comment ➕ 新しいコメント - 📂 Browse… 📂 参照… + Browse… 参照… {words} words {words} 語 {selected} of {words} words {selected}/{words} 語 Direction: 方向: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6c58f5ecc8..6e5d1c0c8b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -68,6 +68,10 @@ Automatic Table 2 (2 levels) Tabela automática 2 (2 níveis) AutoSave Salvamento automático AutoSave saves every change to the file (needs a saved document) O salvamento automático grava cada alteração no arquivo (requer um documento já salvo) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. O salvamento automático não está disponível no navegador: ele não pode gravar nos seus arquivos. Salvar baixa uma cópia. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. O salvamento automático só mantém arquivos .docx, .odt e .rtf; {name} não guarda toda a formatação. Salve como .docx para ativar o salvamento automático. + Save the document to turn on AutoSave Salve o documento para ativar o salvamento automático + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Salve o documento para ativar o salvamento automático (um arquivo que você apenas abriu não é regravado até você salvá-lo) AutoText Autotexto Backspace Backspace Banded Columns Colunas listradas @@ -810,8 +814,7 @@ Zoom Out Reduzir {group} settings Configurações de {group} {key}+Click to follow link {key}+clique para seguir o link - ➕ New comment ➕ Novo comentário - 📂 Browse… 📂 Procurar… + Browse… Procurar… {words} words Palavras: {words} {selected} of {words} words Palavras: {words}; seleção: {selected} Dark page (white text on black) Página escura (texto branco sobre preto) diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8a580c3dc5..6b8333fdad 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -120,6 +120,10 @@ Shape Effects Efekti oblika 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) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatsko čuvanje nije dostupno u pregledaču: ne može da upisuje u vaše fajlove. Čuvanje preuzima kopiju. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Automatsko čuvanje čuva samo .docx, .odt i .rtf fajlove; {name} ne može da zadrži svo formatiranje. Sačuvajte kao .docx da biste uključili automatsko čuvanje. + Save the document to turn on AutoSave Sačuvajte dokument da biste uključili automatsko čuvanje + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Sačuvajte dokument da biste uključili automatsko čuvanje (fajl koji ste samo otvorili ne prepisuje se dok ga ne sačuvate) AutoText Automatski tekst Backspace Backspace Banded Columns Trakaste kolone @@ -857,8 +861,7 @@ {group} settings Podešavanja za {group} {key}+Click to follow link {key}+klik za otvaranje veze {size} pt {size} pt - ➕ New comment ➕ Novi komentar - 📂 Browse… 📂 Potraži… + Browse… Potraži… {words} words {words} reči {selected} of {words} words {selected} od {words} reči Direction: Smer: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d36de7befb..a0be51df82 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -124,6 +124,10 @@ Shape Effects Ефекти облика 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) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Аутоматско чување није доступно у прегледачу: не може да уписује у ваше фајлове. Чување преузима копију. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Аутоматско чување чува само .docx, .odt и .rtf фајлове; {name} не може да задржи сво форматирање. Сачувајте као .docx да бисте укључили аутоматско чување. + Save the document to turn on AutoSave Сачувајте документ да бисте укључили аутоматско чување + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Сачувајте документ да бисте укључили аутоматско чување (фајл који сте само отворили не преписује се док га не сачувате) AutoText Аутоматски текст Backspace Backspace Banded Columns Тракасте колоне @@ -861,8 +865,7 @@ {group} settings Подешавања за {group} {key}+Click to follow link {key}+клик за отварање везе {size} pt {size} пт - ➕ New comment ➕ Нови коментар - 📂 Browse… 📂 Потражи… + Browse… Потражи… {words} words {words} речи {selected} of {words} words {selected} од {words} речи Direction: Смер: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f480b4f57d..fa82e68d58 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -70,6 +70,10 @@ Automatic Table 2 (2 levels) Автоматичний зміст 2 (2 рівні) AutoSave Автозбереження AutoSave saves every change to the file (needs a saved document) Автозбереження записує кожну зміну у файл (документ потрібно спочатку зберегти) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Автозбереження недоступне в браузері: воно не може записувати у ваші файли. Збереження завантажує копію. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Автозбереження підтримує лише файли .docx, .odt і .rtf; {name} не може зберегти все форматування. Збережіть як .docx, щоб увімкнути автозбереження. + Save the document to turn on AutoSave Збережіть документ, щоб увімкнути автозбереження + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Збережіть документ, щоб увімкнути автозбереження (файл, який ви лише відкрили, не перезаписується, доки ви його не збережете) AutoText Автоматичний текст Backspace Видалити попередній символ Banded Columns Смугасті стовпці @@ -836,8 +840,7 @@ Zoom Out Зменшити масштаб {group} settings Параметри групи «{group}» {key}+Click to follow link {key}+клацання, щоб перейти за посиланням - ➕ New comment ➕ Новий коментар - 📂 Browse… 📂 Огляд… + Browse… Огляд… {words} words Слів: {words} {selected} of {words} words Слів: {words}; виділено: {selected} Apply Styles Застосувати стилі diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8444af4f5f..0dd799d52b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -84,6 +84,10 @@ Search for templates 搜索模板 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) 自动保存会把每次更改写入文件(文档需已保存) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. 浏览器中无法使用自动保存:它不能写入你的文件。保存会下载一份副本。 + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. 自动保存仅适用于 .docx、.odt 和 .rtf 文件;{name} 无法保留全部格式。另存为 .docx 即可开启自动保存。 + Save the document to turn on AutoSave 保存文档后即可开启自动保存 + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) 保存文档后即可开启自动保存(仅打开过的文件在你保存之前不会被改写) AutoText 自动图文集 Backspace 退格 Banded Columns 镶边列 @@ -821,8 +825,7 @@ Zoom Out 缩小 {group} settings {group}设置 {key}+Click to follow link 按住 {key} 并单击可访问链接 - ➕ New comment ➕ 新建批注 - 📂 Browse… 📂 浏览… + Browse… 浏览… {words} words {words} 个字 {selected} of {words} words {selected}/{words} 个字 Direction: 方向: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a390dcde18..25a17a43f0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -74,6 +74,10 @@ Search for templates 搜尋範本 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) 自動儲存會將每次變更寫入檔案(文件需已儲存) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. 瀏覽器中無法使用自動儲存:它不能寫入您的檔案。儲存會下載一份複本。 + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. 自動儲存僅適用於 .docx、.odt 和 .rtf 檔案;{name} 無法保留所有格式。另存為 .docx 即可開啟自動儲存。 + Save the document to turn on AutoSave 儲存文件後即可開啟自動儲存 + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) 儲存文件後即可開啟自動儲存(僅開啟過的檔案在您儲存之前不會被改寫) AutoText 自動圖文集 Backspace 退格 Banded Columns 帶狀欄 @@ -811,8 +815,7 @@ Zoom Out 縮小 {group} settings {group}設定 {key}+Click to follow link 按住 {key} 並按一下以追蹤連結 - ➕ New comment ➕ 新增註解 - 📂 Browse… 📂 瀏覽… + Browse… 瀏覽… {words} words {words} 個字 {selected} of {words} words {selected}/{words} 個字 Direction: 方向: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4379f96c60..aaf5639042 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -349,6 +349,9 @@ impl WordApp { if let Some(r) = self.ui_command(id, ¶ms) { return r; } + if id == "file.autosave" { + return self.set_autosave(¶ms); + } let ctx = self.ctx.clone(); if let Some(r) = zotero::command(self, id, ¶ms, ctx.as_ref()) { if let Err(e) = &r { @@ -782,9 +785,40 @@ impl WordApp { self.session.path.is_some() && self.session.path == self.autosave_path } - /// Whether AutoSave covers the document (only a file the user saved to in this session). + /// Why AutoSave can't cover the document, or `None` when it can: it writes only a file the + /// user saved to in this session, in a format that keeps everything, and never in the browser + /// (a page can't write to the user's files; saving there is a download, #176). + pub fn autosave_block(&self) -> Option { + if self.services.download.is_some() { + return Some(AutoSaveBlock::Browser); + } + let Some(path) = &self.session.path else { return Some(AutoSaveBlock::Unsaved) }; + if !keeps_everything(&path.to_string_lossy()) { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + return Some(AutoSaveBlock::Format { name }); + } + if !self.saved_here() { + return Some(AutoSaveBlock::NotSavedHere); + } + None + } + + /// Whether AutoSave covers the document: it is on and nothing blocks it ([`Self::autosave_block`]). pub fn autosaves(&self) -> bool { - self.autosave && self.saved_here() + self.autosave && self.autosave_block().is_none() + } + + /// `file.autosave` (the AutoSave switch, and scripts): `value` turns AutoSave on or off; + /// without it, it toggles what the switch shows. Turning it on for a document AutoSave can't + /// cover is an error that says why, instead of a switch that is on but saves nothing (#196). + fn set_autosave(&mut self, params: &Value) -> Result { + let on = params.get("value").and_then(Value::as_bool).unwrap_or(!self.autosaves()); + if on && let Some(block) = self.autosave_block() { + return Err(block.reason()); + } + self.autosave = on; + self.session.autosave = on; + Ok(json!({"value": on})) } /// AutoSave: write a saved document a couple of seconds after the last change. A failure is @@ -792,9 +826,7 @@ impl WordApp { fn autosave_tick(&mut self, now: f64) { if self.autosaves() && self.session.dirty && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 { self.last_autosave = now; - if self.session.path.as_deref().is_some_and(|p| keeps_everything(&p.to_string_lossy())) - && let Err(e) = self.session.run("file.save", &json!({})) - { + if let Err(e) = self.session.run("file.save", &json!({})) { log::warn!("AutoSave failed: {e}"); self.autosave_path = None; self.status(i18n::fmt(tl!("AutoSave failed: {error}. Save the document to turn AutoSave back on."), &[("error", &e.to_string())])); @@ -954,6 +986,44 @@ impl WordApp { } } +/// Why AutoSave can't cover the document ([`WordApp::autosave_block`]); the switch is greyed out +/// and its tooltip gives [`AutoSaveBlock::reason`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AutoSaveBlock { + /// The web app: saving is a download, there is no file to keep writing to. + Browser, + /// A new document that has never been saved. + Unsaved, + /// A file in a format that can't keep everything (Markdown, plain text, HTML…), by file name. + Format { name: String }, + /// A file that was opened but not yet saved in this session. + NotSavedHere, +} + +impl AutoSaveBlock { + /// What the user (or a script) is told, in the interface language. + pub fn reason(&self) -> String { + match self { + AutoSaveBlock::Browser => { + tl!("AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy.").to_string() + } + AutoSaveBlock::Unsaved => tl!("Save the document to turn on AutoSave").to_string(), + AutoSaveBlock::Format { name } => i18n::fmt( + tl!("AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave."), + &[("name", name)], + ), + AutoSaveBlock::NotSavedHere => { + tl!("Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it)").to_string() + } + } + } + + /// Whether Save As can lift the block (everywhere but the browser). + pub fn save_as_helps(&self) -> bool { + !matches!(self, AutoSaveBlock::Browser) + } +} + /// Whether saving to `name` keeps the whole document (the formats `file.save` treats as the /// document's own file, including macro-enabled documents and templates); anything else is a /// copy that leaves it unsaved. AutoSave writes only these formats. @@ -1900,6 +1970,56 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #196: a saved .md file showed AutoSave on, but AutoSave writes only formats that keep + /// everything, so edits stayed unsaved. The switch is now off and blocked with a reason, and + /// scripts turning it on get that reason as an error; a .docx saved here is covered. + #[test] + fn autosave_is_blocked_with_a_reason_for_files_it_cannot_keep() { + let dir = scratch("autosave-md"); + let md = dir.join("notes.md"); + std::fs::write(&md, "# Notes\n\nSome text\n").unwrap(); + let mut a = app(); + assert_eq!(a.autosave_block(), Some(AutoSaveBlock::Unsaved), "a new document has no file yet"); + a.run("file.open", json!({"path": md.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Typed "})).unwrap(); + a.run("file.save", json!({})).unwrap(); + assert!(a.autosave, "the preference stays on"); + assert_eq!(a.autosave_block(), Some(AutoSaveBlock::Format { name: "notes.md".into() })); + assert!(!a.autosaves(), "the switch shows off"); + let why = a.autosave_block().map(|b| b.reason()).unwrap_or_default(); + assert!(why.contains("notes.md") && why.contains(".docx"), "{why}"); + let err = a.run("file.autosave", json!({"value": true})).unwrap_err(); + assert_eq!(err, why, "scripts are told why"); + assert!(a.run("file.autosave", json!({"value": false})).is_ok(), "turning it off always works"); + a.autosave = true; + + // Save As .docx (what the greyed switch offers) lifts the block. + let docx = dir.join("notes.docx"); + a.run("file.saveAs", json!({"path": docx.to_string_lossy()})).unwrap(); + assert_eq!(a.autosave_block(), None); + assert!(a.autosaves()); + a.run("text.insert", json!({"text": "More "})).unwrap(); + a.autosave_tick(now_ms() + 10_000.0); + assert!(!a.session.dirty, "AutoSave wrote the .docx"); + assert_eq!(a.run("file.autosave", json!({})).unwrap(), json!({"value": false}), "toggling turns it off"); + assert!(!a.autosaves()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #176: in the browser saving is a download, so AutoSave can't keep a file up to date; the + /// switch is blocked with a reason instead of being on while nothing is saved. + #[test] + fn autosave_is_unavailable_in_the_browser() { + let services = Services { download: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + a.run("text.insert", json!({"text": "Hello"})).unwrap(); + a.run("file.save", json!({"path": "draft.docx"})).unwrap(); + assert_eq!(a.autosave_block(), Some(AutoSaveBlock::Browser)); + assert!(!a.autosaves()); + let err = a.run("file.autosave", json!({"value": true})).unwrap_err(); + assert!(err.contains("browser"), "{err}"); + } + /// Cancelling the Open picker (Mod+O) left the document as it was but turned AutoSave off. #[test] fn cancelling_open_keeps_autosave() { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index b0bd89be19..91438d2da5 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -51,7 +51,14 @@ fn header(ui: &mut Ui, title: &str) -> bool { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("✕").clicked() { + // Drawn, not the "✕" character: the interface fonts have no glyph for it. + let t = crate::theme::Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 14.0)), "close", t.icon, t.accent); + if resp.on_hover_text(tl!("Close")).clicked() { close = true; } }); @@ -271,7 +278,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.commentsPane", json!({"value": false})); return; } - if ui.button(tl!("➕ New comment")).clicked() { + if crate::widgets::icon_text_button(ui, "newComment", tl!("New Comment"), regular(12.5)).clicked() { let _ = app.run("review.newComment", json!({"text": ""})); } ui.separator(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3ade93f7df..a3cdf6ba54 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -128,21 +128,24 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(6.0); let track = app.session.doc.settings.track_changes; - ui.menu_button( - egui::RichText::new(format!("✎ {} ▾", if track { tl!("Reviewing") } else { tl!("Editing") })).font(regular(12.0)), - |ui| { - if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { - let _ = app.run("review.trackChanges", json!({"value": false})); - ui.close(); - } - if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { - let _ = app.run("review.trackChanges", json!({"value": true})); - ui.close(); - } - }, + let mode = crate::widgets::icon_text_button( + ui, + "pencil", + &format!("{} ▾", if track { tl!("Reviewing") } else { tl!("Editing") }), + regular(12.0), ); + egui::Popup::menu(&mode).show(|ui| { + if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { + let _ = app.run("review.trackChanges", json!({"value": false})); + ui.close(); + } + if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { + let _ = app.run("review.trackChanges", json!({"value": true})); + ui.close(); + } + }); ui.add_space(4.0); - if ui.button(egui::RichText::new(format!("💬 {}", tl!("Comments"))).font(regular(12.0))).clicked() { + if crate::widgets::icon_text_button(ui, "comment", tl!("Comments"), regular(12.0)).clicked() { let _ = app.run("view.commentsPane", json!({})); } }); @@ -375,7 +378,16 @@ fn home(app: &mut WordApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak()); ui.horizontal(|ui| { for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] { - if ui.button(egui::RichText::new(c).size(16.0)).clicked() { + // The interface fonts have no ➢, so its tile draws the arrowhead. + let label = if c == "➢" { " " } else { c }; + let resp = ui.button(egui::RichText::new(label).size(16.0)); + if c == "➢" { + let m = resp.rect.center(); + let pts = [(-4.0, -5.0), (5.0, 0.0), (-4.0, 5.0), (-1.5, 0.0)].map(|(x, y)| m + vec2(x, y)); + ui.painter().add(egui::Shape::convex_polygon(vec![pts[0], pts[1], pts[3]], Tokens::get(ui.ctx()).text, Stroke::NONE)); + ui.painter().add(egui::Shape::convex_polygon(vec![pts[3], pts[1], pts[2]], Tokens::get(ui.ctx()).text, Stroke::NONE)); + } + if resp.clicked() { let _ = app.run("para.bullets", json!({"kind": c})); ui.close(); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a30a090f8a..68c87314bd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -418,3 +418,53 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { pub fn c32(c: wordcraft_doc::Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + /// Symbols written as text in the interface code must exist in the interface fonts (Inter and + /// egui's emoji fallbacks); a missing glyph draws as an empty box, as the panes' "✕" close + /// button once did. Letters are the catalogs' business (see the i18n font tests). + #[test] + fn interface_symbols_have_glyphs() { + let ctx = egui::Context::default(); + ctx.set_fonts(super::font_definitions(false, false)); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut symbols = BTreeSet::new(); + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let code = std::fs::read_to_string(&path).unwrap(); + // Characters inside string literals, line by line (good enough for this code base). + for line in code.lines().filter(|l| !l.trim_start().starts_with("//")) { + for (i, part) in line.split('"').enumerate() { + if i % 2 == 1 { + symbols.extend( + part.chars() + .filter(|c| !c.is_ascii() && !c.is_alphabetic() && !c.is_whitespace()) + .map(|c| (c, path.file_name().unwrap().to_string_lossy().to_string())), + ); + } + } + } + } + // Equation gallery templates are UnicodeMath input (`lim┬(n→∞)`, `≜`) that the math + // renderer draws with document fonts, not interface text. + // The ➢ bullet is a value for `para.bullets`; its menu tile draws the shape instead. + let exempt = |c: char, file: &str| match file { + "equation_tab.rs" => matches!(c, '≜' | '┬'), + "ribbon.rs" => c == '➢', + _ => false, + }; + symbols.retain(|(c, file)| !exempt(*c, file)); + let font = super::regular(12.5); + let missing: Vec = ctx.fonts_mut(|f| { + symbols.iter().filter(|(c, _)| !f.has_glyph(&font, *c)).map(|(c, file)| format!("{c} U+{:04X} in {file}", *c as u32)).collect() + }); + assert!(missing.is_empty(), "symbols without a glyph: {missing:#?}"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5585691298..5ae4fc3b5d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -390,6 +390,28 @@ pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], prev out } +/// A text button with a drawn icon in front. Symbols such as ✎ or 💬 aren't in the interface +/// fonts and would draw as empty boxes, so icons are painted (`icons.rs`) rather than typed. +pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(text.to_string(), font, t.text); + let pad = ui.spacing().button_padding; + let icon_w = 16.0; + let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y); + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let v = ui.style().interact(&resp); + ui.painter().rect(r, v.corner_radius, v.weak_bg_fill, v.bg_stroke, egui::StrokeKind::Inside); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(r.min.x + pad.x + icon_w / 2.0, r.center().y), vec2(icon_w, icon_w)), + icon, + t.icon, + t.accent, + ); + ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, t.text); + resp +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0a441f38a..f382b94c68 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -36,6 +36,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) | | 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) | | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed | | 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory |