From 82b31efd675c21b2ff47305c4ba5b57e34ddbbfc Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sat, 10 Oct 2026 21:36:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?UI:=20draw=20icons=20instead=20of=20symbols?= =?UTF-8?q?=20the=20interface=20fonts=20lack=20(=E2=9C=95=20=E2=9C=8E=20?= =?UTF-8?q?=F0=9F=92=AC=20=E2=9E=95=20=F0=9F=93=82=20=E2=8B=AF=20=E2=9E=A2?= =?UTF-8?q?)=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several buttons put symbol characters in their text that neither Inter nor egui's emoji fallbacks contain, so they drew as empty boxes: the panes' close button (✕), the title-row Editing/Reviewing (✎) and Comments (💬) buttons, the Comments pane's New comment (➕), File › Open's Browse… (📂), the Equation tab's symbol-set button (⋯) and the ➢ tile in the Bullet Library. - Pane close buttons paint the existing `close` icon, with a Close tooltip. - New `widgets::icon_text_button` paints an icon from icons.rs next to text; Editing/Reviewing (pencil), Comments and New Comment use it. The Editing/Reviewing menu is now an egui Popup on that button. - Browse… drops the emoji; ⋯ becomes …; the ➢ bullet stays a bullet choice and only its menu tile draws the arrowhead. - Catalogs: `📂 Browse…` is renamed `Browse…` (translation without the emoji) and `➕ New comment` is removed in favour of the existing `New Comment`, in every language. theme::tests::interface_symbols_have_glyphs scans the UI sources' string literals for non-letter, non-ASCII characters and checks each against the real interface font setup, with a short commented exception list (equation templates drawn by the math renderer, the ➢ bullet value). Claude-Session: https://claude.ai/code/session_01WtHKQ7wrZm3uBuaG1Ye8po Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 2 +- crates/ui-egui/src/equation_tab.rs | 2 +- crates/ui-egui/src/i18n/es.tsv | 3 +- crates/ui-egui/src/i18n/ja.tsv | 3 +- crates/ui-egui/src/i18n/pt-br.tsv | 3 +- crates/ui-egui/src/i18n/sr-latn.tsv | 3 +- crates/ui-egui/src/i18n/sr.tsv | 3 +- crates/ui-egui/src/i18n/uk.tsv | 3 +- crates/ui-egui/src/i18n/zh-hans.tsv | 3 +- crates/ui-egui/src/i18n/zh-hant.tsv | 3 +- crates/ui-egui/src/panes.rs | 11 +++++-- crates/ui-egui/src/ribbon.rs | 40 +++++++++++++++-------- crates/ui-egui/src/theme.rs | 50 +++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 22 +++++++++++++ 14 files changed, 117 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 80d3f777b4..41a1e85c0c 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); 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 6aead4fe85..dde77cf802 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -819,8 +819,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 a835285b66..c085c60da2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -790,8 +790,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 8aec1974d3..0f73e1ee5a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -790,8 +790,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 d0fb78548e..4f5b7a5094 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -798,8 +798,7 @@ 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… + 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 925c6b3c9d..80ee087511 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -802,8 +802,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/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cdf03b337f..50bf777cd6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -817,8 +817,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 2608695d6a..1f8abfd536 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -801,8 +801,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 baf569b6ff..ab809147ec 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -791,8 +791,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/panes.rs b/crates/ui-egui/src/panes.rs index cd19b58344..70267b9bf5 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -44,7 +44,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; } }); @@ -216,7 +223,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 fb3dda4421..992f6246f5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -120,21 +120,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!({})); } }); @@ -366,7 +369,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::*; From 4a9078b91229e63ec36fec66a24a83d9f651a9a6 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:37:22 +0200 Subject: [PATCH 2/2] Grey out the AutoSave switch, with the reason, where AutoSave can't save (#196, #176) (#302) AutoSave writes only a file saved in this session, in a format that keeps everything (.docx, .docm, .dotx, .dotm, .odt, .rtf, .json), and never in the browser, where saving is a download. The title-bar switch still showed on for a saved .md file (#196) and AutoSave looked available in the web app (#176), so edits stayed unsaved with no explanation. WordApp::autosave_block() now says why AutoSave can't cover the document (browser, never saved, a format that can't keep everything, opened but not saved here). The switch is greyed out with that reason as its tooltip, and clicking it offers Save As (desktop) so saving as .docx turns AutoSave on. In the browser, File > Options' AutoSave checkbox is disabled with the same explanation. file.autosave (scripts, MCP, the switch) now sets the app's AutoSave and returns the reason as an error when asked to turn it on where it can't save; turning it off always works. New strings in every catalog. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/backstage.rs | 11 ++- crates/ui-egui/src/chrome.rs | 45 +++++----- crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/sr-latn.tsv | 4 + crates/ui-egui/src/i18n/sr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/lib.rs | 130 ++++++++++++++++++++++++++-- docs/ui-parity.md | 1 + 13 files changed, 194 insertions(+), 27 deletions(-) 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 41a1e85c0c..0842063782 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -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/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dde77cf802..c9694e7fd9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -60,6 +60,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}) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c085c60da2..25293c6419 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -63,6 +63,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 縞模様(列) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0f73e1ee5a..c557267867 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -61,6 +61,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 diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 4f5b7a5094..31e6f6f383 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -71,6 +71,10 @@ Right Margin Desna margina 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 diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 80ee087511..5e643c1884 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -75,6 +75,10 @@ Right Margin Десна маргина 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 Тракасте колоне diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50bf777cd6..062a2383a9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -63,6 +63,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 Смугасті стовпці diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1f8abfd536..f04cfae121 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.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 镶边列 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab809147ec..f7eab58a9c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -64,6 +64,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 帶狀欄 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/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 |