Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 5574b32600 Release 0.17.0 2026-11-09 06:34:22 -05:00
FredApps 8dc3f0cd5f Merge pull request #4481 from fluorine/dialog-tabs-remaining
Dialogs: draw real tabs in five more dialogs
2026-11-09 11:55:52 +01:00
FredApps f1b1077327 Merge pull request #3802 from fluorine/search-box-text-centred
Search box: the placeholder text is centred vertically (#3779)
2026-11-09 11:55:43 +01:00
Dylan M. Taylor 7b8c0ba5eb Merge pull request #6070 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 23:01:20 -05:00
dotCooCoo 0b61f8b426 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:58 -08:00
Dylan M. Taylor 0a5d5d29f8 Merge pull request #6038 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:29 -05:00
dotCooCoo 642f5dd6bf Updates: the updater reports installing, ready, installed and failed, and installs at exit
Moves craft-update, craft-pdf and the krilla patch from storytold/craft-libs
c5a1cbfb to dotCooCoo/craft-libs c7c84230, which is craft-libs main at
0a9d1f48 with storytold/craft-libs#687 and #696 merged in.

- The result window opens whenever the updater's status is not idle: at
  the first start after an update it says the version that was installed
  or why the update failed, and Help > Check for Updates shows Installing,
  Ready and the launcher's choice. A check asked for from the ribbon now
  shows the window on the next frame.
- Restart to Update closes the window and asks the app to quit. An update
  that asks the app to close quits every window, after asking about each
  document with unsaved changes.
- on_exit and the macOS terminate path start the waiting update's helper.
- The update prompt and the result window draw their words through the
  interface catalog.
2026-11-08 10:56:53 -08:00
fluorineandClaude Sonnet 5.5 7834197fbc Tabs: literal labels for the string extractor, and a test for the tab strip's selected state
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:47:38 -04:00
fluorine 938e13e9c7 Merge remote-tracking branch 'upstream/main' into tmp 2026-11-04 12:19:56 -04:00
fluorineandClaude Sonnet 5.5 1787766d6f Merge upstream main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 11:51:52 -04:00
Fluorine e34840a78a Merge main and port dialog tabs to modular files 2026-10-27 08:47:20 -04:00
fluorineandClaude Sonnet 5.5 066af3b620 Dialog tab tests: pin the UI language to English
The tab queries use English labels, so the host locale must not change them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:15:51 -04:00
fluorineandClaude Sonnet 5.5 d0f273bbfd Dialogs: tabs only; drop the size-keeping page wrapper
Keep the five tab conversions. Remove dialog_page and the Symbol button
move. The test now checks which page a tab shows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:59:14 -04:00
fluorineandClaude Sonnet 5.5 541483ce0b Dialogs: real tabs in Colors, Symbol, Borders and Shading, AutoCorrect, Customize Ribbon
Use dialog_tabs in five more dialogs. Add dialog_page: every page of one
dialog takes the size of the largest page shown so far, so the dialog
keeps its size when the tab changes. The Symbol dialog draws its Insert
and Close buttons below the page for the same reason.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:39:47 -04:00
Fluorine 678af4368d Merge remote-tracking branch 'upstream/main' into search-box-text-centred 2026-10-25 10:33:30 -04:00
fluorineandClaude Sonnet 5.5 fd27713d20 Search box: the placeholder text is centred vertically (#3779)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:45:41 -04:00
14 changed files with 289 additions and 86 deletions
Generated
+22 -23
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -2981,7 +2981,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"arboard",
"chrono",
@@ -6844,7 +6844,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -6857,11 +6857,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.1"
version = "0.17.0"
[[package]]
name = "wordcraft-doc"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"log",
@@ -6875,7 +6875,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"cfb",
"chrono",
@@ -6892,7 +6892,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6917,7 +6917,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"cfb",
"chrono",
@@ -6950,7 +6950,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6964,7 +6964,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6991,7 +6991,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"image",
"serde",
@@ -6999,7 +6999,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"craft-proof",
"image",
@@ -7019,7 +7019,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"log",
"serde_json",
@@ -7031,7 +7031,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7039,7 +7039,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -7060,7 +7060,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"image",
"log",
@@ -7074,7 +7074,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7107,7 +7107,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -7128,7 +7128,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -7291,7 +7291,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.17.0"
dependencies = [
"serde_json",
]
@@ -7562,7 +7562,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+4 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.15.1"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,9 +67,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+3
View File
@@ -133,6 +133,7 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
self.0.start_pending_update();
}
}
@@ -192,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
if let Some(key_file) = key_file {
key_file.remove();
}
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
app.start_pending_update();
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
+1 -9
View File
@@ -600,15 +600,7 @@ fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
);
let pfont = TypeRung::Control.regular();
let ptrack = TypeRung::Control.tracking();
paint_text(
ui.painter(),
pos2(crate::rtl::lead_x(rtl, r, 30.0), r.center().y - TypeRung::Control.line_height() / 2.0),
crate::rtl::lead_top(rtl),
ph,
&pfont,
ptrack,
t.text_dim,
);
paint_text(ui.painter(), pos2(crate::rtl::lead_x(rtl, r, 30.0), r.center().y), crate::rtl::lead_center(rtl), ph, &pfont, ptrack, t.text_dim);
if app.ui.keytips == crate::keytips::Phase::Tabs {
app.keytip_rects.push((r, crate::keytips::SEARCH_LETTER.to_string()));
}
+5 -8
View File
@@ -490,14 +490,11 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
ui.set_width(430.0);
ui.label(egui::RichText::new(tl!("Colors")).font(crate::theme::semibold(14.0)));
ui.add_space(6.0);
ui.horizontal(|ui| {
for (tab, label) in [(ColorsTab::Standard, "Standard"), (ColorsTab::Custom, "Custom")] {
if ui.selectable_label(d.tab == tab, tl!(label)).clicked() {
d.tab = tab;
}
}
});
ui.separator();
let names = [tl!("Standard").to_string(), tl!("Custom").to_string()];
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, d.tab as usize) {
d.tab = if k == 0 { ColorsTab::Standard } else { ColorsTab::Custom };
}
ui.add_space(6.0);
ui.horizontal(|ui| {
crate::rtl::vertical(ui, |ui| {
ui.set_width(330.0);
@@ -269,14 +269,11 @@ fn symbol_cell(app: &mut WordApp, ui: &mut Ui, font: &str, c: char, selected: bo
pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool {
let t = Tokens::get(ui.ctx());
ui.set_width(GRID_COLS as f32 * CELL + 24.0);
ui.horizontal(|ui| {
for (i, l) in ["Symbols", "Special Characters"].into_iter().enumerate() {
if ui.selectable_label(f.tab == i as u8, tl!(l)).clicked() {
f.tab = i as u8;
}
}
});
ui.separator();
let names = [tl!("Symbols").to_string(), tl!("Special Characters").to_string()];
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, f.tab as usize) {
f.tab = k as u8;
}
ui.add_space(6.0);
let mut insert: Option<(Value, String, String)> = None;
if f.tab == 1 {
if special_ui(app, ui, f, &mut insert, &t) {
+5 -8
View File
@@ -557,14 +557,11 @@ impl BordersForm {
/// The Borders and Shading dialog body. Returns true to close.
pub fn borders(app: &mut WordApp, ui: &mut Ui, f: &mut BordersForm) -> bool {
ui.horizontal(|ui| {
for (i, l) in ["Borders", "Page Border", "Shading"].into_iter().enumerate() {
if ui.selectable_label(f.tab == i as u8, tl!(l)).clicked() {
f.tab = i as u8;
}
}
});
ui.separator();
let names = [tl!("Borders").to_string(), tl!("Page Border").to_string(), tl!("Shading").to_string()];
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, f.tab as usize) {
f.tab = k as u8;
}
ui.add_space(6.0);
let theme = app.session.doc.settings.theme_colors.clone();
match f.tab {
0 => {
+5 -8
View File
@@ -396,14 +396,11 @@ fn list_change(app: &mut WordApp, f: &mut AutoCorrectForm, params: Value) -> boo
}
fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bool {
ui.horizontal(|ui| {
for (i, l) in ["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"].into_iter().enumerate() {
if ui.selectable_label(f.tab == i as u8, tl!(l)).clicked() {
f.tab = i as u8;
}
}
});
ui.separator();
let names = [tl!("AutoCorrect").to_string(), tl!("AutoFormat As You Type").to_string(), tl!("Math AutoCorrect").to_string()];
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, f.tab as usize) {
f.tab = k as u8;
}
ui.add_space(6.0);
match f.tab {
0 => autocorrect_tab(app, ui, f),
1 => {
+8 -11
View File
@@ -88,17 +88,14 @@ fn label_of(app: &WordApp, id: &str) -> String {
/// The dialog body; true closes it.
pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> bool {
ui.horizontal(|ui| {
for (page, name) in [(Page::Ribbon, "Ribbon"), (Page::QuickAccess, "Quick Access Toolbar")] {
if ui.selectable_label(f.page == page, tl!(name)).clicked() && f.page != page {
f.page = page;
f.renaming = None;
f.confirm_reset = false;
f.message.clear();
}
}
});
ui.separator();
let names = [tl!("Ribbon").to_string(), tl!("Quick Access Toolbar").to_string()];
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, (f.page == Page::QuickAccess) as usize) {
f.page = if k == 0 { Page::Ribbon } else { Page::QuickAccess };
f.renaming = None;
f.confirm_reset = false;
f.message.clear();
}
ui.add_space(6.0);
let (left, right, h) = (150.0, 230.0, 300.0);
ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0));
let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect();
+2 -2
View File
@@ -274,7 +274,7 @@ fn paragraph_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
else {
return false;
};
let names = ["Indents and Spacing", "Line and Page Breaks", "Asian Typography"].map(|n| tl!(n).to_string());
let names = [tl!("Indents and Spacing").to_string(), tl!("Line and Page Breaks").to_string(), tl!("Asian Typography").to_string()];
if let Some(k) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = k as u8;
}
@@ -1161,7 +1161,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
return false;
};
ui.set_width(820.0);
let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string());
let names = [tl!("About").to_string(), tl!("Contributors").to_string(), tl!("Models").to_string(), tl!("Language data").to_string()];
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = i as u8;
}
+3
View File
@@ -813,5 +813,8 @@ pub(crate) fn rust_sources(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
files
}
#[cfg(test)]
mod tests_dialog_tabs;
#[cfg(test)]
mod tests;
+95
View File
@@ -0,0 +1,95 @@
//! Dialogs with real tabs: a click on a tab shows its page.
use egui_kittest::kittest::Queryable;
use serde_json::json;
use wordcraft_engine::Session;
use crate::WordApp;
use crate::dialogs::Dialog;
fn harness(name: &str) -> egui_kittest::Harness<'static, WordApp> {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let _ = app.run("ui.language", json!({"value": "en"}));
let _ = app.run("document.setText", json!({"text": "Some text"}));
let _ = app.run("select.all", json!({}));
let _ = app.run("ui.dialog", json!({"name": name}));
if app.dialog.is_none() && app.colors.is_none() {
app.dialog = Dialog::open(name, &mut app);
}
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_accessibility_check(false).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.ctx.set_fonts(crate::theme::font_definitions(false, false));
h.ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Tofu);
settle(&mut h);
h
}
fn settle(h: &mut egui_kittest::Harness<'static, WordApp>) {
for _ in 0..4 {
h.step();
}
}
fn tab_button<'a>(h: &'a egui_kittest::Harness<'static, WordApp>, label: &'a str) -> egui_kittest::Node<'a> {
h.query_all_by_role_and_label(egui::accesskit::Role::Button, label).last().unwrap_or_else(|| panic!("no {label} button"))
}
/// Click each tab in turn. After every click `page` must report its index.
fn check(name: &str, tabs: &[&str], page: impl Fn(&WordApp) -> usize) {
let mut h = harness(name);
assert_eq!(page(h.state()), 0, "{name}: opens on the first tab");
for (i, label) in tabs.iter().enumerate() {
tab_button(&h, label).click();
settle(&mut h);
assert_eq!(page(h.state()), i, "{name}: the {label} tab shows its page");
}
}
#[test]
fn colors_tabs_switch_pages() {
check("colors", &["Standard", "Custom"], |app| app.colors.as_ref().map_or(9, |d| d.tab as usize));
}
#[test]
fn symbol_tabs_switch_pages() {
check("symbol", &["Symbols", "Special Characters"], |app| match &app.dialog {
Some(Dialog::Insert(d)) => match &**d {
crate::dialogs_insert::InsertDialog::Symbol(f) => f.tab as usize,
_ => 9,
},
_ => 9,
});
}
#[test]
fn borders_tabs_switch_pages() {
check("borders", &["Borders", "Page Border", "Shading"], |app| match &app.dialog {
Some(Dialog::Borders { form }) => form.tab as usize,
_ => 9,
});
}
#[test]
fn autocorrect_tabs_switch_pages() {
check("autoCorrect", &["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"], |app| match &app.dialog {
Some(Dialog::Review(d)) => match &**d {
crate::dialogs_review::ReviewDialog::AutoCorrect(f) => f.tab as usize,
_ => 9,
},
_ => 9,
});
}
#[test]
fn customize_ribbon_tabs_switch_pages() {
check("customizeRibbon", &["Ribbon", "Quick Access Toolbar"], |app| match &app.dialog {
Some(Dialog::CustomizeRibbon { form }) => (form.page == crate::dialogs_ribbon::Page::QuickAccess) as usize,
_ => 9,
});
}
+110 -5
View File
@@ -29,9 +29,9 @@ impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate),
#[cfg(not(target_arch = "wasm32"))]
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
@@ -45,6 +45,13 @@ impl Default for Runtime {
}
}
/// The update prompt's and the result window's words in the interface language: craft-update
/// passes its English text and the values of its `{name}` placeholders each time it draws.
#[cfg(not(target_arch = "wasm32"))]
fn translate(text: &str, args: &[(&str, &str)]) -> String {
crate::i18n::fmt(crate::i18n::t(text), args)
}
impl WordApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
@@ -59,7 +66,15 @@ impl WordApp {
pub fn disable_self_update(&mut self) {
let rt = &mut self.updates_rt;
rt.startup_check = false;
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_source(refused_check());
rt.updater = Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).with_translator(translate).with_source(refused_check());
}
/// Starts the helper of an update that is ready, so that it installs once the process has
/// ended. Call it once the exit is certain, before a path that ends the process without
/// dropping the app. Later calls do nothing.
#[cfg(not(target_arch = "wasm32"))]
pub fn start_pending_update(&mut self) {
self.updates_rt.updater.start_pending_update();
}
}
@@ -107,15 +122,23 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) {
pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
// An update starts, and a ready one asks the app to close, only while no work is unsaved.
let clean = !(app.session.dirty || app.save_unconfirmed() || app.printing.busy() || crate::panes::has_comment_drafts(app));
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
// The whole app quits, every window included, and first asks about each document with
// unsaved changes.
Action::Close => {
let _ = app.ask_before_quit();
}
Action::None => {}
}
// The dialog answers Help ▸ Check for Updates; a found update leaves the status idle and the prompt above takes over.
// It also reports what the last update came to, which the check at start leaves as the status.
let rt = &mut app.updates_rt;
if !matches!(rt.updater.status(), Status::Idle) {
rt.manual_window_open = true;
}
if rt.manual_window_open {
match rt.result_dialog.show(ctx, rt.updater.status()) {
CheckResultResponse::None => {}
@@ -124,6 +147,13 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
rt.updater.dismiss_status();
}
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
// The window closes, and the next frame's `show_if` asks the app to quit once the
// shown document has no unsaved work.
CheckResultResponse::RestartToUpdate => {
rt.updater.restart_to_update();
rt.manual_window_open = false;
rt.updater.dismiss_status();
}
}
// An update was found: the prompt replaces the dialog.
if matches!(rt.updater.status(), Status::Idle) {
@@ -178,4 +208,79 @@ mod tests {
assert!(matches!(status, Status::Failed(why) if why.contains("another program's AppImage")), "{status:?}");
assert!(!app.updates_rt.updater.prompt_open(), "no update offered");
}
/// Help ▸ Check for Updates runs while the ribbon draws, after the frame's `tick`: the result
/// window shows the check from the next frame on.
#[test]
fn a_check_asked_for_while_drawing_opens_the_result_window() {
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let pins = std::env::temp_dir().join(format!("wordcraft-no-pins-{}.toml", std::process::id()));
app.updates_rt.updater = craft_update::updater::Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION"))
.with_source(std::sync::Arc::new(|| Err("offline".into())))
.with_pins_path(pins);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
super::tick(&mut app, ui.ctx());
super::check(&mut app);
super::show(&mut app, ui.ctx());
});
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
super::tick(&mut app, ui.ctx());
super::show(&mut app, ui.ctx());
});
out.textures_delta.clear();
let status = app.updates_rt.updater.status();
assert!(matches!(status, Status::Checking | Status::Failed(_)), "{status:?}");
assert!(app.updates_rt.manual_window_open, "the result window shows the check ({status:?})");
}
/// The first start after an update reports what it came to without a menu click: the check at
/// start reads the record, the result window opens, and Esc closes it and clears the status.
#[test]
fn the_last_update_opens_the_result_window_until_it_is_closed() {
let version = env!("CARGO_PKG_VERSION");
// The files craft-update writes for an installed update and for a failed one.
let cases: [(&str, &str, fn(&Status) -> bool); 2] = [
("data/self-update-done.txt", version, |s| matches!(s, Status::Updated(v) if v == env!("CARGO_PKG_VERSION"))),
("data/logs/self-update-error.txt", "disk full", |s| matches!(s, Status::InstallFailed(why) if why == "disk full")),
];
for (n, (file, text, expected)) in cases.into_iter().enumerate() {
let root = std::env::temp_dir().join(format!("wordcraft-last-update-{n}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let record = root.join(file);
std::fs::create_dir_all(record.parent().unwrap()).unwrap();
std::fs::write(&record, text).unwrap();
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
app.updates_rt.updater = craft_update::updater::Updater::new(&crate::UPDATE_APP, version)
.with_source(std::sync::Arc::new(|| Ok(None)))
.with_paths(craft_update::Paths::under(&root))
.with_pins_path(root.join("no-pins.toml"));
app.set_startup_update_check(true);
let ctx = egui::Context::default();
let frame = |app: &mut crate::WordApp, events: Vec<egui::Event>| {
let input = egui::RawInput { events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
super::tick(app, ui.ctx());
super::show(app, ui.ctx());
});
out.textures_delta.clear();
};
frame(&mut app, Vec::new());
let status = app.updates_rt.updater.status();
assert!(expected(status), "{file}: {status:?}");
assert!(app.updates_rt.manual_window_open, "{file}: the result window opens by itself");
assert!(!record.exists(), "{file}: the record is read once");
frame(&mut app, Vec::new());
assert!(app.updates_rt.manual_window_open, "{file}: the window stays open");
let escape =
egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, vec![escape]);
assert!(!app.updates_rt.manual_window_open, "{file}: Esc closes the window");
assert_eq!(app.updates_rt.updater.status(), &Status::Idle, "{file}: closing clears the status");
frame(&mut app, Vec::new());
assert!(!app.updates_rt.manual_window_open, "{file}: the window stays closed");
let _ = std::fs::remove_dir_all(&root);
}
}
}
+21
View File
@@ -530,3 +530,24 @@ fn a_long_combo_list_builds_only_the_rows_in_view() {
})
.drop_without_applying_deltas();
}
/// The tab strip exposes each tab as a button with its selected state, and a click on an
/// unselected tab reports its index.
#[test]
fn dialog_tabs_expose_the_selected_state_and_report_clicks() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 60.0)).build_ui_state(
|ui, st: &mut (usize, Option<usize>)| {
if let Some(i) = dialog_tabs(ui, &["One".to_string(), "Two".to_string()], st.0) {
st.1 = Some(i);
}
},
(0usize, None),
);
h.run_steps(2);
assert_eq!(h.get_by_label("One").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
assert_eq!(h.get_by_label("Two").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False));
h.get_by_label("Two").click();
h.run_steps(2);
assert_eq!(h.state().1, Some(1));
}