From ab3ad718a0d41b10c36d6135c0fc34296bd33874 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 17 Oct 2026 17:08:24 +0200 Subject: [PATCH 1/7] feat(ui): remove documents from the recent list, one by one or all (#2716) Hovering a row in File > Home/Open > Recent shows a Remove button at its right end, and a Clear all button under the list empties it. The commands behind them, ui.recentRemove and ui.recentClear, work the same for the CLI, the control channel and MCP. On the desktop the new list is written to the preferences at once, so a removal survives the app being killed before a normal close. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + apps/wordcraft/src/main.rs | 1 + crates/ui-egui/src/backstage.rs | 66 +++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/et.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sr-latn.tsv | 4 + crates/ui-egui/src/i18n/sr.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.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 | 114 ++++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 27 files changed, 270 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09fe..7bb1dfdd11 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-17 | Recent documents can be taken off the list (#2716): hovering a row in File › Home/Open ▸ Recent shows a × that removes that document, a Clear all button under the list empties it, and the change is written to the preferences right away, so it survives a crash. `ui.recentRemove {"path"}` and `ui.recentClear` do the same for CLI, control and MCP | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | @@ -512,6 +513,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) | | 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) | +| 2026-10-17 | trivial | Progress log: removing documents from the recent list (#2716) | | 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) | | 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) | | 2026-10-18 | trivial | Progress log: Picture Format › Size, sizes typed in any unit and the Size dialog (#2573); readiness estimates unchanged | diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index db5b2488ac..b77a44e952 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -250,6 +250,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { set_clipboard_html: Some(Box::new(clipboard::set_html)), clipboard_rtf: Some(Box::new(clipboard::rtf)), clipboard_formats: Some(Box::new(clipboard::formats)), + save_prefs: Some(Box::new(save_prefs)), ..Default::default() } } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ac07f8424f..c69b2d0af7 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -354,6 +354,9 @@ fn new_page(app: &mut WordApp, ui: &mut Ui) { }); } +/// Width of the × that sits at the right end of a recent row (#2716). +const REMOVE_W: f32 = 28.0; + fn open_list(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!("Recent")).font(semibold(16.0))); @@ -366,19 +369,39 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { if app.ui.recent.is_empty() { ui.label(egui::RichText::new(tl!("Documents you open will show up here.")).color(t.text_dim)); } - for p in app.ui.recent.iter().take(app.ui.recent_shown()).cloned().collect::>() { + let shown = app.ui.recent.iter().take(app.ui.recent_shown()).cloned().collect::>(); + for p in shown { let name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone()); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click()); + // #2716: each row carries its own Remove, as Word's context menu does. The × is a button + // of its own at the right end, so it never opens the document; clicking the rest of the + // row opens it as before. + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0) - REMOVE_W, 40.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &name)); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent); ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, &name, medium(13.0), t.text); ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, &p, regular(11.0), t.text_dim); + let xr = Rect::from_min_max(pos2(r.max.x + 2.0, r.min.y + 4.0), pos2(r.max.x + REMOVE_W, r.max.y - 4.0)); + let xresp = ui.interact(xr, ui.id().with(("recent-remove", &p)), Sense::click()); + xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Remove from list"))); + if xresp.hovered() { + ui.painter().rect_filled(xr, 4.0, t.hover); + ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", medium(15.0), t.text); + } if resp.clicked() { let failed = open_recent(app, &p).err(); ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default())); } + if xresp.clicked() { + let _ = app.run("ui.recentRemove", json!({"path": p})); + } + } + if app.ui.recent.iter().take(app.ui.recent_shown()).next().is_some() + && ui.add(egui::Button::new(egui::RichText::new(tl!("Clear all")).font(medium(13.0)))).clicked() + { + let _ = app.run("ui.recentClear", json!({})); } } @@ -696,6 +719,45 @@ mod tests { assert!(ids("no such template").is_empty()); } + /// #2716: each recent row has its own Remove (× at the right end, so it opens nothing), and + /// Clear all takes the whole list away; the list is empty afterwards. + #[test] + fn recent_rows_can_be_removed_one_by_one_or_all_at_once() { + use egui::accesskit::Role; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + a.run("ui.backstage", json!({"value": true, "page": "home"})).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); + let row = |h: &egui_kittest::Harness, name: &str| h.query_all_by_role_and_label(Role::Button, name).next().map(|n| n.rect()); + assert!(row(&h, "alpha.docx").is_some() && row(&h, "beta.docx").is_some(), "both rows show"); + // The × inside alpha's row, not beta's: click it and only alpha goes. + let remove_in = |h: &mut egui_kittest::Harness, name: &str| { + let r = row(h, name).expect(name); + h.query_all_by_role_and_label(Role::Button, "Remove from list") + .find(|n| n.rect().min.y >= r.min.y && n.rect().min.y < r.max.y) + .expect("row has its Remove") + .click(); + }; + remove_in(&mut h, "alpha.docx"); + h.run_steps(4); + assert_eq!(h.state().ui.recent, ["/tmp/beta.docx".to_string()], "the row's own Remove takes its row"); + assert!(h.state().ui.backstage, "the × does not open the Backstage away"); + assert!(row(&h, "alpha.docx").is_none() && row(&h, "beta.docx").is_some()); + h.get_by_label("Clear all").click(); + h.run_steps(4); + assert!(h.state().ui.recent.is_empty(), "Clear all empties the list"); + assert!(h.query_by_label("Documents you open will show up here.").is_some()); + assert!(row(&h, "beta.docx").is_none(), "and Clear all goes with it"); + } + /// Home and New are different pages (#141): Home has the short template row, the link to New /// and Recent; New has every template and no Recent list. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1994f2a154..56e4669cfa 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2688,3 +2688,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Въведете размер в произволна единица, например 5 cm или 300 px. Другата страна се променя съответно, така че картината запазва пропорциите си. Import… Импортиране… + Clear all Изчисти всичко + Remove from list Премахване от списъка + Removed {name} from the recent list {name} е премахнат от списъка с последни документи + Removed {count} documents from the recent list От списъка с последни документи премахнати: {count} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a7c5060735..2b6bb3220d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2691,3 +2691,7 @@ preview Title Ukázka Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Zadejte velikost v libovolné jednotce, například 5 cm nebo 300 px. Druhá strana se přizpůsobí, takže obrázek zachová své proporce. Import… Importovat… + Clear all Vymazat vše + Remove from list Odebrat ze seznamu + Removed {name} from the recent list {name} byl odebrán ze seznamu nedávno otevřených dokumentů + Removed {count} documents from the recent list Ze seznamu nedávno otevřených dokumentů odebráno: {count} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eeb70c65e8..ed97b9692e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2287,3 +2287,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält. Import… Importieren… + Clear all Alle löschen + Remove from list Aus Liste entfernen + Removed {name} from the recent list {name} wurde aus der Liste der zuletzt verwendeten Dokumente entfernt + Removed {count} documents from the recent list Aus der Liste der zuletzt verwendeten Dokumente entfernt: {count} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19ef8fcf53..270714c27e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2654,3 +2654,7 @@ preview Title Título Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Escribe un tamaño en cualquier unidad, como 5 cm o 300 px. El otro lado se ajusta, así que la imagen conserva sus proporciones. Import… Importar… + Clear all Borrar todo + Remove from list Quitar de la lista + Removed {name} from the recent list {name} se quitó de la lista de documentos recientes + Removed {count} documents from the recent list {count} documentos se quitaron de la lista de documentos recientes diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 0aa9e52c17..925723a6a1 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2322,3 +2322,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid. Import… Impordi… + Clear all Puhasta kõik + Remove from list Eemalda nimekirjast + Removed {name} from the recent list {name} eemaldatud hiljutiste dokumentide nimekirjast + Removed {count} documents from the recent list Hiljutiste dokumentide nimekirjast eemaldatud: {count} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44c9cb9377..ef518d4714 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2700,3 +2700,7 @@ template Letter Lettre Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Tapez une taille dans n'importe quelle unité, par exemple 5 cm ou 300 px. L'autre côté suit, et l'image garde ses proportions. Import… Importer… + Clear all Effacer tout + Remove from list Supprimer de la liste + Removed {name} from the recent list {name} a été supprimé de la liste des documents récents + Removed {count} documents from the recent list {count} documents supprimés de la liste des documents récents diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 15bbbb54d4..f2fb901665 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2699,3 +2699,7 @@ preview Title Naslov Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Upišite veličinu u bilo kojoj jedinici, npr. 5 cm ili 300 px. Druga se strana prilagođava pa slika zadržava svoje proporcije. Import… Uvezi… + Clear all Izbriši sve + Remove from list Ukloni s liste + Removed {name} from the recent list {name} je uklonjen s liste nedavnih dokumenata + Removed {count} documents from the recent list S liste nedavnih dokumenata uklonjeno: {count} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33951fee5f..e987c8fecd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2695,3 +2695,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Írj be egy méretet bármilyen mértékegységben, például 5 cm vagy 300 px. A másik oldal követi, így a kép megtartja az arányait. Import… Importálás… + Clear all Az összes törlése + Remove from list Eltávolítás a listából + Removed {name} from the recent list {name} eltávolítva a legutóbbi dokumentumok listájából + Removed {count} documents from the recent list {count} dokumentum eltávolítva a legutóbbi dokumentumok listájából diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3dd2b6d61d..001a5929cb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2708,3 +2708,7 @@ preview Title Esempio Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Scrivi una dimensione in qualsiasi unità, ad esempio 5 cm o 300 px. L'altro lato si adegua, così l'immagine mantiene le proporzioni. Import… Importa… + Clear all Cancella tutto + Remove from list Rimuovi dall'elenco + Removed {name} from the recent list {name} rimosso dall'elenco dei documenti recenti + Removed {count} documents from the recent list {count} documenti rimossi dall'elenco dei documenti recenti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d633eac511..de7f12dbe4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2288,3 +2288,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。 Import… インポート… + Clear all すべてクリア + Remove from list 一覧から削除 + Removed {name} from the recent list 最近使用したファイルの一覧から {name} を削除しました + Removed {count} documents from the recent list 最近使用したファイルの一覧から {count} 件のファイルを削除しました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7e50812e09..b8d3bb4af2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2542,3 +2542,7 @@ preview Title 제목 Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm 또는 300 px처럼 원하는 단위로 크기를 입력하세요. 다른 쪽도 함께 바뀌어 그림의 비율이 유지됩니다. Import… 가져오기… + Clear all 모두 지우기 + Remove from list 목록에서 삭제 + Removed {name} from the recent list 최근 문서 목록에서 {name}을(를) 삭제했습니다 + Removed {count} documents from the recent list 최근 문서 목록에서 문서 {count}개를 삭제했습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ccbeb471fe..75e4bf9d67 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2704,3 +2704,7 @@ preview Title Tytuł Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Wpisz rozmiar w dowolnej jednostce, np. 5 cm lub 300 px. Druga strona dopasuje się, więc obraz zachowa proporcje. Import… Importuj… + Clear all Wyczyść wszystko + Remove from list Usuń z listy + Removed {name} from the recent list {name} został usunięty z listy ostatnio używanych dokumentów + Removed {count} documents from the recent list Z listy ostatnio używanych dokumentów usunięto: {count} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a1038e1a7..768c2a9177 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2300,3 +2300,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções. Import… Importar… + Clear all Limpar tudo + Remove from list Remover da lista + Removed {name} from the recent list {name} foi removido da lista de documentos recentes + Removed {count} documents from the recent list {count} documentos foram removidos da lista de documentos recentes diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cd064b4eeb..5222a4b15c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2697,3 +2697,7 @@ preview Title Пример Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введите размер в любых единицах, например 5 cm или 300 px. Другая сторона изменится вместе с ней, и рисунок сохранит пропорции. Import… Импорт… + Clear all Очистить всё + Remove from list Удалить из списка + Removed {name} from the recent list {name} удалён из списка недавних документов + Removed {count} documents from the recent list Из списка недавних документов удалено: {count} diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8e0a9136c2..6677e1dba8 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2298,3 +2298,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije. Import… Uvezi… + Clear all Obriši sve + Remove from list Ukloni iz liste + Removed {name} from the recent list {name} je uklonjen iz liste nedavnih dokumenata + Removed {count} documents from the recent list Iz liste nedavnih dokumenata uklonjeno: {count} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c55fa2c7e4..336132e13e 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2302,3 +2302,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције. Import… Увези… + Clear all Обриши све + Remove from list Уклони из листе + Removed {name} from the recent list {name} је уклоњен из листе недавних докумената + Removed {count} documents from the recent list Из листе недавних докумената уклоњено: {count} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2098fc2784..e2c51d8042 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2714,3 +2714,7 @@ preview Title Titel Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Skriv en storlek i valfri enhet, till exempel 5 cm eller 300 px. Den andra sidan följer med, så att bilden behåller sina proportioner. Import… Importera… + Clear all Rensa allt + Remove from list Ta bort från listan + Removed {name} from the recent list {name} har tagits bort från listan över senaste dokument + Removed {count} documents from the recent list {count} dokument har tagits bort från listan över senaste dokument diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 50799c62de..640dfb2b4a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2696,3 +2696,7 @@ preview Title Örnek Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Herhangi bir birimde bir boyut yazın, örneğin 5 cm veya 300 px. Diğer kenar da buna uyar, böylece resim oranlarını korur. Import… İçe aktar… + Clear all Tümünü temizle + Remove from list Listeden kaldır + Removed {name} from the recent list {name} son kullanılan belgeler listesinden kaldırıldı + Removed {count} documents from the recent list Son kullanılan belgeler listesinden kaldırılan belge sayısı: {count} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f327585957..ca3130cd76 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2300,3 +2300,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції. Import… Імпортувати… + Clear all Очистити все + Remove from list Вилучити зі списку + Removed {name} from the recent list {name} вилучено зі списку останніх документів + Removed {count} documents from the recent list Останніх документів вилучено зі списку: {count} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2bfe75ec2..0ab3eebb7b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2739,3 +2739,7 @@ template Letter Thư Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Nhập kích thước theo đơn vị bất kỳ, ví dụ 5 cm hoặc 300 px. Cạnh còn lại thay đổi theo, nên ảnh giữ nguyên tỷ lệ. Import… Nhập… + Clear all Xóa tất cả + Remove from list Xóa khỏi danh sách + Removed {name} from the recent list Đã xóa {name} khỏi danh sách tài liệu gần đây + Removed {count} documents from the recent list Đã xóa {count} tài liệu khỏi danh sách tài liệu gần đây diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d733ca8f00..8590c3c7a0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2300,3 +2300,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。 Import… 导入… + Clear all 全部清除 + Remove from list 从列表移除 + Removed {name} from the recent list 已从最近使用的文档列表中移除 {name} + Removed {count} documents from the recent list 已从最近使用的文档列表中移除 {count} 个文档 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a8bc708ea1..2a2e5f9c5b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2289,3 +2289,7 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。 Import… 匯入… + Clear all 全部清除 + Remove from list 從清單移除 + Removed {name} from the recent list 已從最近使用的檔案清單中移除 {name} + Removed {count} documents from the recent list 已從最近使用的檔案清單中移除 {count} 個檔案 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7f687a4e8..6ff33df5fe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -171,6 +171,10 @@ pub struct Services { /// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder /// instead. An error means nothing changed. pub make_default_app: Option Result>>, + /// Desktop: write the app's current preferences out now, not only at exit (#2716): a + /// document taken off the recent list should survive the app being killed before a normal + /// close. `None` (web, tests) keeps the change to this run. + pub save_prefs: Option>, } /// How Save in the Save Changes prompt went. @@ -1357,6 +1361,9 @@ impl WordApp { for req in std::mem::take(&mut self.session.ui_requests) { self.handle_request(&req); } + // The open document goes to the front of the recent list after every command. The recent + // commands (`ui.recentRemove`, `ui.recentClear`) return from `ui_command` before this + // runs, so taking a document off the list is never undone by the re-add (#2716). if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string()) && self.ui.recent.first() != Some(&p) { @@ -1714,6 +1721,30 @@ impl WordApp { } json!({"startScreen": self.ui.start_screen}) } + "ui.recentRemove" => { + // Backstage ▸ Home/Open ▸ Recent: take one document off the list (Word's context + // menu Remove). The path must be an entry that is on the list (#2716). + let Some(path) = p.get("path").and_then(Value::as_str) else { + return Some(Err("`path` must name a document on the recent list".into())); + }; + let before = self.ui.recent.len(); + self.ui.recent.retain(|r| r != path); + if self.ui.recent.len() == before { + return Some(Err(format!("`{path}` is not on the recent list"))); + } + self.save_prefs_now(); + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); + self.status(i18n::fmt(tl!("Removed {name} from the recent list"), &[("name", &name)])); + json!({"removed": 1, "recent": self.ui.recent}) + } + "ui.recentClear" => { + // Clear the whole list, as Word's registry clear does for recent documents (#2716). + let removed = self.ui.recent.len(); + self.ui.recent.clear(); + self.save_prefs_now(); + self.status(i18n::fmt(tl!("Removed {count} documents from the recent list"), &[("count", &removed.to_string())])); + json!({"removed": removed, "recent": self.ui.recent}) + } // Shape Format › Text › Create Link: the next click on an empty text box links the // selected box to it (`shape.link` with that box as the target); Esc cancels. // Picture Format › Size › Crop: crop mode on the selected picture, or off (`crop`). @@ -1767,6 +1798,16 @@ impl WordApp { self.status_msg = Some((s.into(), now_ms())); } + /// Write the current preferences out through the host's hook, if it has one (#2716). The + /// desktop app saves them at exit; a change that should survive a crash, like taking a + /// document off the recent list, needs a save at the moment of the change. + fn save_prefs_now(&mut self) { + let hook = self.services.save_prefs.as_ref(); + if let Some(save) = hook { + save(self); + } + } + /// Media key of the selected picture, if any. pub(crate) fn selected_picture_media(&self) -> Option { match wordcraft_engine::cmd::objects::selected(&self.session) { @@ -2618,6 +2659,8 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "ui.discord" | "ui.caretAnimation" | "ui.startScreen" + | "ui.recentRemove" + | "ui.recentClear" ) || id.starts_with("help.") } @@ -7930,4 +7973,75 @@ mod tests { h.step(); assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar"); } + + /// Issue #2716: taking documents off the recent list is final — the open document must not be + /// pushed back by the after-command re-add — and the change goes to the settings at once, so + /// it survives the app being killed. Junk params return errors, never panics. + #[test] + fn recent_list_edits_are_final_and_persist() { + let saved = std::rc::Rc::new(std::cell::Cell::new(0u32)); + let saved_hook = saved.clone(); + let mut a = app(); + a.services.save_prefs = Some(Box::new(move |app: &WordApp| { + saved_hook.set(saved_hook.get() + 1); + let _ = serde_json::to_string(&app.prefs()).unwrap(); + })); + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + // The open document is on the list, at the back. + a.session.path = Some(std::path::PathBuf::from("/tmp/beta.docx")); + + // Removing the open document sticks: `after_command` must not re-add it. + let r = a.run("ui.recentRemove", json!({"path": "/tmp/beta.docx"})).unwrap(); + assert_eq!(r["removed"], 1); + assert_eq!(r["recent"], json!(["/tmp/alpha.docx"])); + assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "the open document stays off the list"); + assert_eq!(saved.get(), 1, "the change is written out at once"); + + // Removing an entry that isn't there is an error, not a silent success, and saves nothing. + assert!(a.run("ui.recentRemove", json!({"path": "/tmp/gamma.docx"})).is_err()); + assert_eq!(saved.get(), 1, "a rejected command writes nothing"); + // Hostile params: missing path, junk types. + for junk in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"path": ["x"]})] { + assert!(a.run("ui.recentRemove", junk).is_err()); + } + assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "junk params change nothing"); + + // A normal command re-adds the open document, as before. + a.run("text.insert", json!({"text": "x"})).unwrap(); + assert_eq!(a.ui.recent.first(), Some(&"/tmp/beta.docx".to_string())); + + // Clearing empties the list (the re-added open document is back, so both entries go) and + // keeps it empty through the next command. + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert_eq!(r["removed"], 2); + assert!(a.ui.recent.is_empty()); + assert_eq!(saved.get(), 2, "the clear is written out at once"); + a.run("text.insert", json!({"text": "y"})).unwrap(); + assert_eq!(a.ui.recent.first(), Some(&"/tmp/beta.docx".to_string()), "the open document comes back after clearing"); + + // Clearing an empty list reports zero removed and is not an error. + a.ui.recent.clear(); + assert_eq!(a.run("ui.recentClear", json!({})).unwrap()["removed"], 0); + + // The list lives in the prefs, so a restart keeps the removal. + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap(); + let saved_prefs = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved_prefs.contains(r#""recent":["/tmp/beta.docx"]"#), "{saved_prefs}"); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved_prefs).unwrap()); + assert_eq!(b.ui.recent, ["/tmp/beta.docx".to_string()]); + } + + /// Issue #2716: taking documents off the list needs no open document — the Start screen shows + /// the recent list, and its buttons must work from there. + #[test] + fn recent_list_edits_work_without_a_document() { + let mut a = app(); + a.ui.recent = vec!["/tmp/alpha.docx".into()]; + assert!(!a.session.dirty && a.session.path.is_none()); + assert!(works_without_document("ui.recentRemove") && works_without_document("ui.recentClear")); + assert_eq!(a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap()["removed"], 1); + assert_eq!(a.run("ui.recentClear", json!({})).unwrap()["removed"], 0); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f85b7587cc..cbb5e53312 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,7 +52,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945)`ui.recentRemove` (`{"path": entry}` takes that document off the recent list — `path` must be an entry on the list; on the desktop the new list is saved right away, #2716), `ui.recentClear` (`{}` clears the whole list; the result reports `removed` and the new `recent`)…) | | `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | From f9c6ddba3a23c7249ce80d130ed813fe2299193f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 21 Oct 2026 20:23:29 +0200 Subject: [PATCH 2/7] fix(ui): show the Remove x on a hovered recent row, not only on the x itself (#2716) The x was painted only while the pointer was already on the 28 pt button, so a hovered row gave no cue that a per-row Remove exists. Paint it dimmed when the row (or the button) is hovered or focused, with the stronger fill only on the x. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/backstage.rs | 44 ++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c69b2d0af7..54b03f08ac 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -354,6 +354,25 @@ fn new_page(app: &mut WordApp, ui: &mut Ui) { }); } +/// How the × of a recent row is drawn (#2716): not at all while nothing is hovered, dimmed while +/// the row is, and with the stronger fill only when the pointer is on the × itself. +#[derive(Debug, PartialEq, Eq)] +enum RemoveCue { + Hidden, + Dim, + Strong, +} + +fn remove_cue(row_active: bool, x_active: bool) -> RemoveCue { + if x_active { + RemoveCue::Strong + } else if row_active { + RemoveCue::Dim + } else { + RemoveCue::Hidden + } +} + /// Width of the × that sits at the right end of a recent row (#2716). const REMOVE_W: f32 = 28.0; @@ -386,9 +405,17 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { let xr = Rect::from_min_max(pos2(r.max.x + 2.0, r.min.y + 4.0), pos2(r.max.x + REMOVE_W, r.max.y - 4.0)); let xresp = ui.interact(xr, ui.id().with(("recent-remove", &p)), Sense::click()); xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Remove from list"))); - if xresp.hovered() { - ui.painter().rect_filled(xr, 4.0, t.hover); - ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", medium(15.0), t.text); + // The × shows (dimmed) as soon as the row or the × is hovered or focused, so the Remove can + // be found; only the × itself gets the stronger fill and full-strength glyph. + match remove_cue(resp.hovered() || resp.has_focus(), xresp.hovered() || xresp.has_focus()) { + RemoveCue::Hidden => {} + RemoveCue::Dim => { + ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", medium(15.0), t.text_dim); + } + RemoveCue::Strong => { + ui.painter().rect_filled(xr, 4.0, t.hover); + ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", medium(15.0), t.text); + } } if resp.clicked() { let failed = open_recent(app, &p).err(); @@ -719,6 +746,17 @@ mod tests { assert!(ids("no such template").is_empty()); } + /// #2716: the × of a recent row is a cue, not a secret: dimmed when only the row is hovered, + /// stronger on the × itself, absent when neither is. + #[test] + fn recent_remove_cross_shows_when_the_row_is_hovered() { + use super::{remove_cue, RemoveCue}; + assert_eq!(remove_cue(false, false), RemoveCue::Hidden); + assert_eq!(remove_cue(true, false), RemoveCue::Dim, "row hovered shows the glyph"); + assert_eq!(remove_cue(false, true), RemoveCue::Strong); + assert_eq!(remove_cue(true, true), RemoveCue::Strong); + } + /// #2716: each recent row has its own Remove (× at the right end, so it opens nothing), and /// Clear all takes the whole list away; the list is empty afterwards. #[test] From 6bc7810c8cb1ad2c2d95fdc155a8bd0942cd3d40 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 21 Oct 2026 23:23:44 +0200 Subject: [PATCH 3/7] fmt: rustfmt backstage recent list --- crates/ui-egui/src/backstage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 54b03f08ac..b867235402 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -750,7 +750,7 @@ mod tests { /// stronger on the × itself, absent when neither is. #[test] fn recent_remove_cross_shows_when_the_row_is_hovered() { - use super::{remove_cue, RemoveCue}; + use super::{RemoveCue, remove_cue}; assert_eq!(remove_cue(false, false), RemoveCue::Hidden); assert_eq!(remove_cue(true, false), RemoveCue::Dim, "row hovered shows the glyph"); assert_eq!(remove_cue(false, true), RemoveCue::Strong); From 914e1d5872538a8a98f4c63c8cdeffad970a2e6e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 00:47:31 +0100 Subject: [PATCH 4/7] Use Ui::scope_id for the recent-row remove button Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/backstage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 0c4ceaa05f..32605c55f5 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -562,7 +562,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text); ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim); let xr = Rect::from_min_max(pos2(r.max.x + 2.0, r.min.y + 4.0), pos2(r.max.x + REMOVE_W, r.max.y - 4.0)); - let xresp = ui.interact(xr, ui.id().with(("recent-remove", &p)), Sense::click()); + let xresp = ui.interact(xr, ui.scope_id().with(("recent-remove", &p)), Sense::click()); xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Remove from list"))); // The × shows (dimmed) as soon as the row or the × is hovered or focused, so the Remove can // be found; only the × itself gets the stronger fill and full-strength glyph. From 9de2227af241cf77562a8ba79dd7e01d8d7d8007 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 1 Nov 2026 13:33:45 +0100 Subject: [PATCH 5/7] Report a failed preferences save from ui.recentRemove and ui.recentClear The save_prefs hook now returns a Result; both commands add saved/saveError to their result and the status line says when the write failed. Co-Authored-By: Claude Sonnet 5.5 --- apps/wordcraft/src/main.rs | 32 +++++++++++++-------------- crates/ui-egui/src/app/commands.rs | 35 ++++++++++++++++++++++++------ crates/ui-egui/src/app/frame.rs | 15 ++++++++----- crates/ui-egui/src/services.rs | 5 +++-- crates/ui-egui/src/tests.rs | 29 +++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index d3948df167..755ba292ef 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -226,25 +226,23 @@ fn saved_window() -> Option { } fn save_prefs(app: &WordApp) { + if let Err(e) = try_save_prefs(app) { + log::warn!("preferences: {e}"); + } +} + +/// Saves the preferences and says why when it could not. Nothing to write (prefs disabled or no +/// settings folder) counts as success. +fn try_save_prefs(app: &WordApp) -> Result<(), String> { if !prefs_enabled() { - return; + return Ok(()); } - if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() - && let Err(e) = std::fs::create_dir_all(dir) - { - log::warn!("preferences: cannot create {}: {e}", dir.display()); - return; - } - match serde_json::to_vec_pretty(&app.prefs()) { - Ok(bytes) => { - if let Err(e) = write_prefs(&p, &bytes) { - log::warn!("preferences: cannot save {}: {e}", p.display()); - } - } - Err(e) => log::warn!("preferences: cannot serialize: {e}"), - } + let Some(p) = prefs_path() else { return Ok(()) }; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; } + let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?; + write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display())) } /// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or @@ -278,7 +276,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { set_clipboard_html: Some(Box::new(clipboard::set_html)), clipboard_rtf: Some(Box::new(clipboard::rtf)), clipboard_formats: Some(Box::new(clipboard::formats)), - save_prefs: Some(Box::new(save_prefs)), + save_prefs: Some(Box::new(try_save_prefs)), ..Default::default() } } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index aac423d133..40336b2401 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -796,18 +796,26 @@ impl WordApp { if self.ui.recent.len() == before { return Some(Err(format!("`{path}` is not on the recent list"))); } - self.save_prefs_now(); - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); - self.status(i18n::fmt(tl!("Removed {name} from the recent list"), &[("name", &name)])); - json!({"removed": 1, "recent": self.ui.recent}) + let saved = self.save_prefs_now(); + if !matches!(saved, Some(Err(_))) { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); + self.status(i18n::fmt(tl!("Removed {name} from the recent list"), &[("name", &name)])); + } + let mut r = json!({"removed": 1, "recent": self.ui.recent}); + add_saved(&mut r, saved); + r } "ui.recentClear" => { // Clear the whole list, as Word's registry clear does for recent documents (#2716). let removed = self.ui.recent.len(); self.ui.recent.clear(); - self.save_prefs_now(); - self.status(i18n::fmt(tl!("Removed {count} documents from the recent list"), &[("count", &removed.to_string())])); - json!({"removed": removed, "recent": self.ui.recent}) + let saved = self.save_prefs_now(); + if !matches!(saved, Some(Err(_))) { + self.status(i18n::fmt(tl!("Removed {count} documents from the recent list"), &[("count", &removed.to_string())])); + } + let mut r = json!({"removed": removed, "recent": self.ui.recent}); + add_saved(&mut r, saved); + r } // Shape Format › Text › Create Link: the next click on an empty text box links the // selected box to it (`shape.link` with that box as the target); Esc cancels. @@ -1025,6 +1033,19 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> { /// Keyboard). /// Everything else needs a document, `ui.*` included: only the interface commands listed here /// (tabs, Backstage, dialogs, appearance, opening files) work without one. +/// The result fields of a command that saves the preferences at once: `saved` is true when the +/// write worked, false with `saveError` when it failed (the change still holds for this run) and +/// null when the host has no preferences hook. +fn add_saved(r: &mut Value, saved: Option>) { + r["saved"] = match &saved { + None => Value::Null, + Some(res) => json!(res.is_ok()), + }; + if let Some(Err(e)) = saved { + r["saveError"] = json!(e); + } +} + pub(crate) fn works_without_document(id: &str) -> bool { matches!( id, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 58519baa45..97fe85e91d 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -5,6 +5,8 @@ use super::{ zotero, }; +use crate::i18n; + impl WordApp { /// A file that wouldn't open (#1103): log it, and say on the status bar which file and why, /// whatever handed it over (the command line, a macOS open event, a drop). Returns the message. @@ -21,12 +23,15 @@ impl WordApp { /// Write the current preferences out through the host's hook, if it has one (#2716). The /// desktop app saves them at exit; a change that should survive a crash, like taking a - /// document off the recent list, needs a save at the moment of the change. - pub(super) fn save_prefs_now(&mut self) { - let hook = self.services.save_prefs.as_ref(); - if let Some(save) = hook { - save(self); + /// document off the recent list, needs a save at the moment of the change. `None`: the host + /// has no hook; `Some(Err)`: the write failed, and the status line says so. + pub(super) fn save_prefs_now(&mut self) -> Option> { + let save = self.services.save_prefs.as_ref()?; + let res = save(self); + if let Err(e) = &res { + self.status(i18n::fmt(tl!("Could not save the preferences: {error}"), &[("error", e)])); } + Some(res) } /// Document title for the title bar (and the suggested file name). An untitled document is diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d1d20f3c37..40eb1efa21 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -90,8 +90,9 @@ pub struct Services { pub make_default_app: Option Result>>, /// Desktop: write the app's current preferences out now, not only at exit (#2716): a /// document taken off the recent list should survive the app being killed before a normal - /// close. `None` (web, tests) keeps the change to this run. - pub save_prefs: Option>, + /// close. An error is the reason the write failed; the recent commands report it. `None` + /// (web, tests) keeps the change to this run, and the commands then report `saved: null`. + pub save_prefs: Option Result<(), String>>>, /// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852), /// used while File › Options names none. File › New lists personal templates from it and Save /// as Word Template starts in it. `None` (web) hides personal templates. diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 3155ec01c9..eff225a92b 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -7199,6 +7199,7 @@ fn recent_list_edits_are_final_and_persist() { a.services.save_prefs = Some(Box::new(move |app: &WordApp| { saved_hook.set(saved_hook.get() + 1); let _ = serde_json::to_string(&app.prefs()).unwrap(); + Ok(()) })); a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; // The open document is on the list, at the back. @@ -7208,6 +7209,7 @@ fn recent_list_edits_are_final_and_persist() { let r = a.run("ui.recentRemove", json!({"path": "/tmp/beta.docx"})).unwrap(); assert_eq!(r["removed"], 1); assert_eq!(r["recent"], json!(["/tmp/alpha.docx"])); + assert_eq!(r["saved"], true); assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "the open document stays off the list"); assert_eq!(saved.get(), 1, "the change is written out at once"); @@ -7247,6 +7249,33 @@ fn recent_list_edits_are_final_and_persist() { assert_eq!(b.ui.recent, ["/tmp/beta.docx".to_string()]); } +/// Issue #2716: when the immediate preferences write fails, both recent commands say so (result +/// and status line) instead of reporting plain success; the removal still holds for this run. +/// A host with no hook reports `saved: null`. +#[test] +fn recent_list_edits_report_a_failed_preferences_save() { + let mut a = app(); + a.services.save_prefs = Some(Box::new(|_: &WordApp| Err("disk full".to_string()))); + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + + let r = a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap(); + assert_eq!((&r["saved"], &r["saveError"]), (&json!(false), &json!("disk full")), "{r}"); + assert_eq!(r["recent"], json!(["/tmp/beta.docx"])); + assert_eq!(a.ui.recent, ["/tmp/beta.docx".to_string()], "the removal holds for this run"); + let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(status.contains("disk full") && !status.starts_with("Removed"), "{status}"); + + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert_eq!((&r["saved"], &r["saveError"]), (&json!(false), &json!("disk full")), "{r}"); + assert_eq!(r["removed"], 1); + assert!(a.ui.recent.is_empty()); + + a.services.save_prefs = None; + a.ui.recent = vec!["/tmp/alpha.docx".into()]; + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert!(r["saved"].is_null() && r.get("saveError").is_none(), "{r}"); +} + /// Issue #2716: taking documents off the list needs no open document — the Start screen shows /// the recent list, and its buttons must work from there. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 999ff83bed..57045cc103 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.recentRemove` (`{"path": entry}` takes that document off the recent list — `path` must be an entry on the list; on the desktop the new list is saved right away, #2716), `ui.recentClear` (`{}` clears the whole list; the result reports `removed` and the new `recent`), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | +| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.recentRemove` (`{"path": entry}` takes that document off the recent list — `path` must be an entry on the list; on the desktop the new list is saved right away: the result's `saved` is true then, false with `saveError` when the write failed (the removal still holds for this run) and null where the host keeps no preferences, #2716), `ui.recentClear` (`{}` clears the whole list; the result reports `removed`, the new `recent` and `saved` as above), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | From 1bbced51a499d5b07bcf8ca594845fc945fc8d11 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 1 Nov 2026 13:53:50 +0100 Subject: [PATCH 6/7] Recent list: saved is null when no preferences were written The desktop hook returns Ok(false) when preferences are switched off or there is no settings folder; the commands report that like a host without a hook. Also move add_saved out of works_without_document's doc comment. Co-Authored-By: Claude Sonnet 5.5 --- apps/wordcraft/src/main.rs | 13 +++++++------ crates/ui-egui/src/app/commands.rs | 14 +++++++------- crates/ui-egui/src/app/frame.rs | 14 +++++++++----- crates/ui-egui/src/services.rs | 8 +++++--- crates/ui-egui/src/tests.rs | 18 +++++++++++------- docs/control-protocol.md | 2 +- 6 files changed, 40 insertions(+), 29 deletions(-) diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 755ba292ef..5ac5c7ee54 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -231,18 +231,19 @@ fn save_prefs(app: &WordApp) { } } -/// Saves the preferences and says why when it could not. Nothing to write (prefs disabled or no -/// settings folder) counts as success. -fn try_save_prefs(app: &WordApp) -> Result<(), String> { +/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written +/// because preferences are switched off or there is no settings folder. +fn try_save_prefs(app: &WordApp) -> Result { if !prefs_enabled() { - return Ok(()); + return Ok(false); } - let Some(p) = prefs_path() else { return Ok(()) }; + let Some(p) = prefs_path() else { return Ok(false) }; if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; } let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?; - write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display())) + write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?; + Ok(true) } /// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 40336b2401..e41d4aed4a 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1027,15 +1027,9 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> { } } -/// Commands that work while no document is open (#1129): opening or creating one, closing the -/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File › -/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and -/// Keyboard). -/// Everything else needs a document, `ui.*` included: only the interface commands listed here -/// (tabs, Backstage, dialogs, appearance, opening files) work without one. /// The result fields of a command that saves the preferences at once: `saved` is true when the /// write worked, false with `saveError` when it failed (the change still holds for this run) and -/// null when the host has no preferences hook. +/// null when the host has no preferences hook or wrote nothing. fn add_saved(r: &mut Value, saved: Option>) { r["saved"] = match &saved { None => Value::Null, @@ -1046,6 +1040,12 @@ fn add_saved(r: &mut Value, saved: Option>) { } } +/// Commands that work while no document is open (#1129): opening or creating one, closing the +/// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File › +/// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and +/// Keyboard). +/// Everything else needs a document, `ui.*` included: only the interface commands listed here +/// (tabs, Backstage, dialogs, appearance, opening files) work without one. pub(crate) fn works_without_document(id: &str) -> bool { matches!( id, diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 97fe85e91d..b7dfc4df04 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -24,14 +24,18 @@ impl WordApp { /// Write the current preferences out through the host's hook, if it has one (#2716). The /// desktop app saves them at exit; a change that should survive a crash, like taking a /// document off the recent list, needs a save at the moment of the change. `None`: the host - /// has no hook; `Some(Err)`: the write failed, and the status line says so. + /// has no hook or wrote nothing; `Some(Err)`: the write failed, and the status line says so. pub(super) fn save_prefs_now(&mut self) -> Option> { let save = self.services.save_prefs.as_ref()?; - let res = save(self); - if let Err(e) = &res { - self.status(i18n::fmt(tl!("Could not save the preferences: {error}"), &[("error", e)])); + match save(self) { + Ok(true) => Some(Ok(())), + // The host wrote nothing (preferences switched off): same as having no hook. + Ok(false) => None, + Err(e) => { + self.status(i18n::fmt(tl!("Could not save the preferences: {error}"), &[("error", &e)])); + Some(Err(e)) + } } - Some(res) } /// Document title for the title bar (and the suggested file name). An untitled document is diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 40eb1efa21..53a0df09b0 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -90,9 +90,11 @@ pub struct Services { pub make_default_app: Option Result>>, /// Desktop: write the app's current preferences out now, not only at exit (#2716): a /// document taken off the recent list should survive the app being killed before a normal - /// close. An error is the reason the write failed; the recent commands report it. `None` - /// (web, tests) keeps the change to this run, and the commands then report `saved: null`. - pub save_prefs: Option Result<(), String>>>, + /// close. `Ok(true)`: written; `Ok(false)`: nothing written because the host keeps no + /// preferences (switched off, no settings folder); an error is the reason the write failed. + /// The recent commands report these as `saved` true / null / false. `None` (web, tests) keeps + /// the change to this run and reports `saved: null`. + pub save_prefs: Option Result>>, /// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852), /// used while File › Options names none. File › New lists personal templates from it and Save /// as Word Template starts in it. `None` (web) hides personal templates. diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index eff225a92b..f8631b2d34 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -7199,7 +7199,7 @@ fn recent_list_edits_are_final_and_persist() { a.services.save_prefs = Some(Box::new(move |app: &WordApp| { saved_hook.set(saved_hook.get() + 1); let _ = serde_json::to_string(&app.prefs()).unwrap(); - Ok(()) + Ok(true) })); a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; // The open document is on the list, at the back. @@ -7251,11 +7251,11 @@ fn recent_list_edits_are_final_and_persist() { /// Issue #2716: when the immediate preferences write fails, both recent commands say so (result /// and status line) instead of reporting plain success; the removal still holds for this run. -/// A host with no hook reports `saved: null`. +/// A host with no hook, or one that wrote nothing, reports `saved: null`. #[test] fn recent_list_edits_report_a_failed_preferences_save() { let mut a = app(); - a.services.save_prefs = Some(Box::new(|_: &WordApp| Err("disk full".to_string()))); + a.services.save_prefs = Some(Box::new(|_: &WordApp| Err::("disk full".to_string()))); a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; let r = a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap(); @@ -7270,10 +7270,14 @@ fn recent_list_edits_report_a_failed_preferences_save() { assert_eq!(r["removed"], 1); assert!(a.ui.recent.is_empty()); - a.services.save_prefs = None; - a.ui.recent = vec!["/tmp/alpha.docx".into()]; - let r = a.run("ui.recentClear", json!({})).unwrap(); - assert!(r["saved"].is_null() && r.get("saveError").is_none(), "{r}"); + // No hook, or a hook that wrote nothing (preferences switched off): `saved` is null. + for hook in [None, Some(Box::new(|_: &WordApp| Ok(false)) as Box Result>)] { + a.services.save_prefs = hook; + a.ui.recent = vec!["/tmp/alpha.docx".into()]; + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert!(r["saved"].is_null() && r.get("saveError").is_none(), "{r}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.starts_with("Removed")), "{:?}", a.status_msg); + } } /// Issue #2716: taking documents off the list needs no open document — the Start screen shows diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 57045cc103..d2f6ebe059 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.recentRemove` (`{"path": entry}` takes that document off the recent list — `path` must be an entry on the list; on the desktop the new list is saved right away: the result's `saved` is true then, false with `saveError` when the write failed (the removal still holds for this run) and null where the host keeps no preferences, #2716), `ui.recentClear` (`{}` clears the whole list; the result reports `removed`, the new `recent` and `saved` as above), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | +| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.recentRemove` (`{"path": entry}` takes that document off the recent list — `path` must be an entry on the list; on the desktop the new list is saved right away: the result's `saved` is true then, false with `saveError` when the write failed (the removal still holds for this run) and null where the host keeps no preferences (the web app, or preferences switched off), #2716), `ui.recentClear` (`{}` clears the whole list; the result reports `removed`, the new `recent` and `saved` as above), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | From 0ff06b9d866679be24f93906f4f1b2d127773f43 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 03:23:46 +0100 Subject: [PATCH 7/7] backstage: fix Align2 path after merge Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/backstage.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 9626b819b6..5eff019009 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -651,11 +651,11 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { match remove_cue(resp.hovered() || resp.has_focus(), xresp.hovered() || xresp.has_focus()) { RemoveCue::Hidden => {} RemoveCue::Dim => { - ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", medium(15.0), t.text_dim); + ui.painter().text(xr.center(), egui::Align2::CENTER_CENTER, "×", medium(15.0), t.text_dim); } RemoveCue::Strong => { ui.painter().rect_filled(xr, 4.0, t.hover); - ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", medium(15.0), t.text); + ui.painter().text(xr.center(), egui::Align2::CENTER_CENTER, "×", medium(15.0), t.text); } } if resp.clicked() {