From 541483ce0b75a37a5e91e9fdd5df381772c92829 Mon Sep 17 00:00:00 2001 From: fluorine Date: Mon, 26 Oct 2026 14:39:47 -0400 Subject: [PATCH 1/4] 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 --- crates/ui-egui/src/colors.rs | 17 ++-- crates/ui-egui/src/dialogs_insert.rs | 66 +++++++------- crates/ui-egui/src/dialogs_para.rs | 17 ++-- crates/ui-egui/src/dialogs_review.rs | 17 ++-- crates/ui-egui/src/dialogs_ribbon.rs | 23 +++-- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/tests_dialog_tabs.rs | 109 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 14 +++ 8 files changed, 187 insertions(+), 79 deletions(-) create mode 100644 crates/ui-egui/src/tests_dialog_tabs.rs diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index 211113b7be..22c560b7b2 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -488,21 +488,18 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { 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 = ["Standard", "Custom"].map(|n| tl!(n).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| { ui.vertical(|ui| { ui.set_width(330.0); - match d.tab { + crate::widgets::dialog_page(ui, "colors", |ui| match d.tab { ColorsTab::Standard => standard(ui, d), ColorsTab::Custom => custom(ui, d), - } + }); }); ui.vertical(|ui| { ui.add_space(110.0); diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index bb555b0035..c79d50c76a 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -822,20 +822,16 @@ fn symbol_cell(app: &mut WordApp, ui: &mut Ui, font: &str, c: char, selected: bo 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 = ["Symbols", "Special Characters"].map(|n| tl!(n).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) { - return true; + let double = crate::widgets::dialog_page(ui, "symbol", |ui| { + if f.tab == 1 { + return special_ui(app, ui, f, &t); } - } else { f.load(); ui.horizontal(|ui| { ui.label(tl!("Font:")); @@ -951,17 +947,15 @@ fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool { ui.label(tl!("Character code:")); }); }); - if double && let Some(p) = f.params() { - insert = Some((p, f.selected.map(String::from).unwrap_or_default(), f.font.clone())); - } - // The Insert button. - let (ok, close) = buttons(ui, "Insert", "Close", f.selected.is_some()); - if ok && let Some(p) = f.params() { - insert = Some((p, f.selected.map(String::from).unwrap_or_default(), f.font.clone())); - } - if close { - return true; - } + double + }); + let (ok, close) = buttons(ui, "Insert", "Close", f.tab == 1 || f.selected.is_some()); + if close { + return true; + } + if ok || double { + insert = + if f.tab == 1 { special_insert(f) } else { f.params().map(|p| (p, f.selected.map(String::from).unwrap_or_default(), f.font.clone())) }; } if let Some((params, ch, font)) = insert { // Special characters with their own command insert through it (non-breaking hyphen…). @@ -976,8 +970,8 @@ fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool { false } -/// The Special Characters tab; returns true to close. -fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut Option<(Value, String, String)>, t: &Tokens) -> bool { +/// The Special Characters tab; returns true after a double click. +fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, t: &Tokens) -> bool { let mut double = false; egui::ScrollArea::vertical().id_salt("special_chars").max_height(CELL * GRID_ROWS as f32 + 60.0).auto_shrink([false, false]).show(ui, |ui| { egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)).show(ui, |ui| { @@ -1001,17 +995,17 @@ fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut O } }); }); - let (ok, close) = buttons(ui, "Insert", "Close", true); - if (ok || double) - && let Some((_, c, cmd)) = SPECIAL.get(f.special) - { - let params = match cmd { - Some(id) => json!({"command": id}), - None => json!({"char": c.to_string()}), - }; - *insert = Some((params, c.to_string(), String::new())); - } - close + double +} + +/// What the Insert button inserts on the Special Characters tab. +fn special_insert(f: &SymbolForm) -> Option<(Value, String, String)> { + let (_, c, cmd) = SPECIAL.get(f.special)?; + let params = match cmd { + Some(id) => json!({"command": id}), + None => json!({"char": c.to_string()}), + }; + Some((params, c.to_string(), String::new())) } // --------------------------------------------------------------------------------------------- diff --git a/crates/ui-egui/src/dialogs_para.rs b/crates/ui-egui/src/dialogs_para.rs index 34654ea8fc..e35543ab6c 100644 --- a/crates/ui-egui/src/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs_para.rs @@ -552,16 +552,13 @@ 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 = ["Borders", "Page Border", "Shading"].map(|n| tl!(n).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 { + crate::widgets::dialog_page(ui, "borders", |ui| match f.tab { 0 => { let to_text = f.to_text; lines(ui, "para", &mut f.para, &theme, Rgb::parse(&f.fill), Target::from(to_text)); @@ -591,7 +588,7 @@ pub fn borders(app: &mut WordApp, ui: &mut Ui, f: &mut BordersForm) -> bool { ui.radio_value(&mut f.fill_to_text, true, tl!("Text")); }); } - } + }); let (ok, cancel) = buttons(ui, tl!("OK")); if ok { f.apply(app); diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index b1f38ccb9e..5ae3904143 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -383,15 +383,12 @@ 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(); - match f.tab { + let names = ["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"].map(|n| tl!(n).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); + crate::widgets::dialog_page(ui, "autocorrect", |ui| match f.tab { 0 => autocorrect_tab(app, ui, f), 1 => { let o = &mut f.options; @@ -412,7 +409,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo ui.checkbox(&mut o.tab_indent, tl!("Set left- and first-indent with tabs and backspaces")); } _ => math_tab(ui, f), - } + }); error(ui, &f.message); let (ok, cancel) = buttons(ui, true); if ok { diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index 094e1b2775..7f3d946cfb 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -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 = ["Ribbon", "Quick Access Toolbar"].map(|n| tl!(n).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(); @@ -123,10 +120,10 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b }); }); ui.add_space(4.0); - match f.page { + crate::widgets::dialog_page(ui, "ribbon", |ui| match f.page { Page::Ribbon => ribbon_page(app, ui, f, h), Page::QuickAccess => qat_page(app, ui, f, h), - } + }); }); if let Some(spec) = app.session.registry.get(&f.command) { ui.label(egui::RichText::new(format!("{} · {}", crate::i18n::location(spec.location), spec.id)).small().weak()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dca58282f5..330b492b08 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3412,6 +3412,9 @@ fn control_click_as_secondary(events: &mut [egui::Event], held: &mut bool) { #[cfg(test)] mod tests_perf; +#[cfg(test)] +mod tests_dialog_tabs; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/tests_dialog_tabs.rs b/crates/ui-egui/src/tests_dialog_tabs.rs new file mode 100644 index 0000000000..68b35bd479 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_tabs.rs @@ -0,0 +1,109 @@ +//! Dialogs with real tabs: a click on a tab shows its page, and the dialog +//! keeps its size when the tab changes. + +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("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, +/// and the tab strip and the `anchor` button must sit where they sat on the +/// first page, so the dialog kept its size. +fn check(name: &str, tabs: &[&str], anchor: &str, page: impl Fn(&WordApp) -> usize) { + let mut h = harness(name); + assert_eq!(page(h.state()), 0, "{name}: opens on the first tab"); + // Visit every page once, so the dialog has grown to its largest page. + for label in tabs { + tab_button(&h, label).click(); + settle(&mut h); + } + let mut tops = vec![]; + 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"); + tops.push(tab_button(&h, label).rect().top()); + tops.push(tab_button(&h, anchor).rect().top()); + } + for pair in tops.chunks(2) { + assert!((pair[0] - tops[0]).abs() <= 0.5, "{name}: the tab strip stays put: {tops:?}"); + assert!((pair[1] - tops[1]).abs() <= 0.5, "{name}: the {anchor} button stays put: {tops:?}"); + } +} + +#[test] +fn colors_tabs_switch_pages_and_keep_the_size() { + check("colors", &["Standard", "Custom"], "OK", |app| app.colors.as_ref().map_or(9, |d| d.tab as usize)); +} + +#[test] +fn symbol_tabs_switch_pages_and_keep_the_size() { + check("symbol", &["Symbols", "Special Characters"], "Insert", |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_and_keep_the_size() { + check("borders", &["Borders", "Page Border", "Shading"], "OK", |app| match &app.dialog { + Some(Dialog::Borders { form }) => form.tab as usize, + _ => 9, + }); +} + +#[test] +fn autocorrect_tabs_switch_pages_and_keep_the_size() { + check("autoCorrect", &["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"], "OK", |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_and_keep_the_size() { + check("customizeRibbon", &["Ribbon", "Quick Access Toolbar"], "Close", |app| match &app.dialog { + Some(Dialog::CustomizeRibbon { form }) => (form.page == crate::dialogs_ribbon::Page::QuickAccess) as usize, + _ => 9, + }); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3a068c8346..f91a3d32fb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1348,6 +1348,20 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option(ui: &mut Ui, key: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + let id = ui.scope_id().with(("dialog_page", key)); + let seen: egui::Vec2 = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_default(); + let out = ui.scope(|ui| { + ui.set_min_size(seen); + add(ui) + }); + ui.ctx().data_mut(|d| d.insert_temp(id, seen.max(out.response.rect.size()))); + out.inner +} + #[cfg(test)] mod tests { use super::*; From d0f273bbfdf38a76130a2632d5bbd9d0f5100a26 Mon Sep 17 00:00:00 2001 From: fluorine Date: Mon, 26 Oct 2026 15:59:14 -0400 Subject: [PATCH 2/4] 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 --- crates/ui-egui/src/colors.rs | 4 +- crates/ui-egui/src/dialogs_insert.rs | 53 +++++++++++++------------ crates/ui-egui/src/dialogs_para.rs | 4 +- crates/ui-egui/src/dialogs_review.rs | 4 +- crates/ui-egui/src/dialogs_ribbon.rs | 4 +- crates/ui-egui/src/tests_dialog_tabs.rs | 41 ++++++------------- crates/ui-egui/src/widgets.rs | 14 ------- 7 files changed, 49 insertions(+), 75 deletions(-) diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index 22c560b7b2..b9397f1f28 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -496,10 +496,10 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { ui.horizontal(|ui| { ui.vertical(|ui| { ui.set_width(330.0); - crate::widgets::dialog_page(ui, "colors", |ui| match d.tab { + match d.tab { ColorsTab::Standard => standard(ui, d), ColorsTab::Custom => custom(ui, d), - }); + } }); ui.vertical(|ui| { ui.add_space(110.0); diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index c79d50c76a..091b5844f4 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -828,10 +828,11 @@ fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool { } ui.add_space(6.0); let mut insert: Option<(Value, String, String)> = None; - let double = crate::widgets::dialog_page(ui, "symbol", |ui| { - if f.tab == 1 { - return special_ui(app, ui, f, &t); + if f.tab == 1 { + if special_ui(app, ui, f, &mut insert, &t) { + return true; } + } else { f.load(); ui.horizontal(|ui| { ui.label(tl!("Font:")); @@ -947,15 +948,17 @@ fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool { ui.label(tl!("Character code:")); }); }); - double - }); - let (ok, close) = buttons(ui, "Insert", "Close", f.tab == 1 || f.selected.is_some()); - if close { - return true; - } - if ok || double { - insert = - if f.tab == 1 { special_insert(f) } else { f.params().map(|p| (p, f.selected.map(String::from).unwrap_or_default(), f.font.clone())) }; + if double && let Some(p) = f.params() { + insert = Some((p, f.selected.map(String::from).unwrap_or_default(), f.font.clone())); + } + // The Insert button. + let (ok, close) = buttons(ui, "Insert", "Close", f.selected.is_some()); + if ok && let Some(p) = f.params() { + insert = Some((p, f.selected.map(String::from).unwrap_or_default(), f.font.clone())); + } + if close { + return true; + } } if let Some((params, ch, font)) = insert { // Special characters with their own command insert through it (non-breaking hyphen…). @@ -970,8 +973,8 @@ fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> bool { false } -/// The Special Characters tab; returns true after a double click. -fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, t: &Tokens) -> bool { +/// The Special Characters tab; returns true to close. +fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut Option<(Value, String, String)>, t: &Tokens) -> bool { let mut double = false; egui::ScrollArea::vertical().id_salt("special_chars").max_height(CELL * GRID_ROWS as f32 + 60.0).auto_shrink([false, false]).show(ui, |ui| { egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)).show(ui, |ui| { @@ -995,17 +998,17 @@ fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, t: &Tokens) -> } }); }); - double -} - -/// What the Insert button inserts on the Special Characters tab. -fn special_insert(f: &SymbolForm) -> Option<(Value, String, String)> { - let (_, c, cmd) = SPECIAL.get(f.special)?; - let params = match cmd { - Some(id) => json!({"command": id}), - None => json!({"char": c.to_string()}), - }; - Some((params, c.to_string(), String::new())) + let (ok, close) = buttons(ui, "Insert", "Close", true); + if (ok || double) + && let Some((_, c, cmd)) = SPECIAL.get(f.special) + { + let params = match cmd { + Some(id) => json!({"command": id}), + None => json!({"char": c.to_string()}), + }; + *insert = Some((params, c.to_string(), String::new())); + } + close } // --------------------------------------------------------------------------------------------- diff --git a/crates/ui-egui/src/dialogs_para.rs b/crates/ui-egui/src/dialogs_para.rs index e35543ab6c..804f2e5fe1 100644 --- a/crates/ui-egui/src/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs_para.rs @@ -558,7 +558,7 @@ pub fn borders(app: &mut WordApp, ui: &mut Ui, f: &mut BordersForm) -> bool { } ui.add_space(6.0); let theme = app.session.doc.settings.theme_colors.clone(); - crate::widgets::dialog_page(ui, "borders", |ui| match f.tab { + match f.tab { 0 => { let to_text = f.to_text; lines(ui, "para", &mut f.para, &theme, Rgb::parse(&f.fill), Target::from(to_text)); @@ -588,7 +588,7 @@ pub fn borders(app: &mut WordApp, ui: &mut Ui, f: &mut BordersForm) -> bool { ui.radio_value(&mut f.fill_to_text, true, tl!("Text")); }); } - }); + } let (ok, cancel) = buttons(ui, tl!("OK")); if ok { f.apply(app); diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 5ae3904143..0ab38d4d57 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -388,7 +388,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo f.tab = k as u8; } ui.add_space(6.0); - crate::widgets::dialog_page(ui, "autocorrect", |ui| match f.tab { + match f.tab { 0 => autocorrect_tab(app, ui, f), 1 => { let o = &mut f.options; @@ -409,7 +409,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo ui.checkbox(&mut o.tab_indent, tl!("Set left- and first-indent with tabs and backspaces")); } _ => math_tab(ui, f), - }); + } error(ui, &f.message); let (ok, cancel) = buttons(ui, true); if ok { diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index 7f3d946cfb..8036605b11 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -120,10 +120,10 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b }); }); ui.add_space(4.0); - crate::widgets::dialog_page(ui, "ribbon", |ui| match f.page { + match f.page { Page::Ribbon => ribbon_page(app, ui, f, h), Page::QuickAccess => qat_page(app, ui, f, h), - }); + } }); if let Some(spec) = app.session.registry.get(&f.command) { ui.label(egui::RichText::new(format!("{} · {}", crate::i18n::location(spec.location), spec.id)).small().weak()); diff --git a/crates/ui-egui/src/tests_dialog_tabs.rs b/crates/ui-egui/src/tests_dialog_tabs.rs index 68b35bd479..5a280bad07 100644 --- a/crates/ui-egui/src/tests_dialog_tabs.rs +++ b/crates/ui-egui/src/tests_dialog_tabs.rs @@ -1,5 +1,4 @@ -//! Dialogs with real tabs: a click on a tab shows its page, and the dialog -//! keeps its size when the tab changes. +//! Dialogs with real tabs: a click on a tab shows its page. use egui_kittest::kittest::Queryable; use serde_json::json; @@ -40,39 +39,25 @@ fn tab_button<'a>(h: &'a egui_kittest::Harness<'static, WordApp>, label: &'a str 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, -/// and the tab strip and the `anchor` button must sit where they sat on the -/// first page, so the dialog kept its size. -fn check(name: &str, tabs: &[&str], anchor: &str, page: impl Fn(&WordApp) -> usize) { +/// 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"); - // Visit every page once, so the dialog has grown to its largest page. - for label in tabs { - tab_button(&h, label).click(); - settle(&mut h); - } - let mut tops = vec![]; 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"); - tops.push(tab_button(&h, label).rect().top()); - tops.push(tab_button(&h, anchor).rect().top()); - } - for pair in tops.chunks(2) { - assert!((pair[0] - tops[0]).abs() <= 0.5, "{name}: the tab strip stays put: {tops:?}"); - assert!((pair[1] - tops[1]).abs() <= 0.5, "{name}: the {anchor} button stays put: {tops:?}"); } } #[test] -fn colors_tabs_switch_pages_and_keep_the_size() { - check("colors", &["Standard", "Custom"], "OK", |app| app.colors.as_ref().map_or(9, |d| d.tab as usize)); +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_and_keep_the_size() { - check("symbol", &["Symbols", "Special Characters"], "Insert", |app| match &app.dialog { +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, @@ -82,16 +67,16 @@ fn symbol_tabs_switch_pages_and_keep_the_size() { } #[test] -fn borders_tabs_switch_pages_and_keep_the_size() { - check("borders", &["Borders", "Page Border", "Shading"], "OK", |app| match &app.dialog { +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_and_keep_the_size() { - check("autoCorrect", &["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"], "OK", |app| match &app.dialog { +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, @@ -101,8 +86,8 @@ fn autocorrect_tabs_switch_pages_and_keep_the_size() { } #[test] -fn customize_ribbon_tabs_switch_pages_and_keep_the_size() { - check("customizeRibbon", &["Ribbon", "Quick Access Toolbar"], "Close", |app| match &app.dialog { +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, }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f91a3d32fb..3a068c8346 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1348,20 +1348,6 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option(ui: &mut Ui, key: &str, add: impl FnOnce(&mut Ui) -> R) -> R { - let id = ui.scope_id().with(("dialog_page", key)); - let seen: egui::Vec2 = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_default(); - let out = ui.scope(|ui| { - ui.set_min_size(seen); - add(ui) - }); - ui.ctx().data_mut(|d| d.insert_temp(id, seen.max(out.response.rect.size()))); - out.inner -} - #[cfg(test)] mod tests { use super::*; From 066af3b620e52d0fb23b330f8f3cf3c158a17676 Mon Sep 17 00:00:00 2001 From: fluorine Date: Mon, 26 Oct 2026 16:15:51 -0400 Subject: [PATCH 3/4] 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 --- crates/ui-egui/src/tests_dialog_tabs.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/tests_dialog_tabs.rs b/crates/ui-egui/src/tests_dialog_tabs.rs index 5a280bad07..476cfe302a 100644 --- a/crates/ui-egui/src/tests_dialog_tabs.rs +++ b/crates/ui-egui/src/tests_dialog_tabs.rs @@ -9,6 +9,7 @@ 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})); From 7834197fbc5239f62879a0a814683c795e637f41 Mon Sep 17 00:00:00 2001 From: fluorine Date: Thu, 5 Nov 2026 10:47:38 -0400 Subject: [PATCH 4/4] Tabs: literal labels for the string extractor, and a test for the tab strip's selected state Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/colors.rs | 2 +- .../src/dialogs/dialogs_insert/symbol.rs | 2 +- crates/ui-egui/src/dialogs/dialogs_para.rs | 2 +- crates/ui-egui/src/dialogs/dialogs_review.rs | 2 +- crates/ui-egui/src/dialogs/dialogs_ribbon.rs | 2 +- crates/ui-egui/src/dialogs/dispatch.rs | 4 ++-- crates/ui-egui/src/widgets/tests.rs | 21 +++++++++++++++++++ 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index c60a56f349..30d2424a4d 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -488,7 +488,7 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { ui.set_width(430.0); ui.label(egui::RichText::new(tl!("Colors")).font(crate::theme::semibold(14.0))); ui.add_space(6.0); - let names = ["Standard", "Custom"].map(|n| tl!(n).to_string()); + 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 }; } diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs index fefa2fcc43..797d6aef72 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs @@ -269,7 +269,7 @@ 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); - let names = ["Symbols", "Special Characters"].map(|n| tl!(n).to_string()); + 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; } diff --git a/crates/ui-egui/src/dialogs/dialogs_para.rs b/crates/ui-egui/src/dialogs/dialogs_para.rs index 8e8c34355d..4c8ec052c7 100644 --- a/crates/ui-egui/src/dialogs/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs/dialogs_para.rs @@ -552,7 +552,7 @@ 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 { - let names = ["Borders", "Page Border", "Shading"].map(|n| tl!(n).to_string()); + 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; } diff --git a/crates/ui-egui/src/dialogs/dialogs_review.rs b/crates/ui-egui/src/dialogs/dialogs_review.rs index 0d1dd70553..d5977dbadb 100644 --- a/crates/ui-egui/src/dialogs/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs/dialogs_review.rs @@ -396,7 +396,7 @@ 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 { - let names = ["AutoCorrect", "AutoFormat As You Type", "Math AutoCorrect"].map(|n| tl!(n).to_string()); + 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; } diff --git a/crates/ui-egui/src/dialogs/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs/dialogs_ribbon.rs index 994af54cc6..8105569077 100644 --- a/crates/ui-egui/src/dialogs/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs/dialogs_ribbon.rs @@ -88,7 +88,7 @@ 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 { - let names = ["Ribbon", "Quick Access Toolbar"].map(|n| tl!(n).to_string()); + 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; diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index 77344875f9..da4ff4837f 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -283,7 +283,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; } @@ -1143,7 +1143,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; } diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs index f97cb4e7df..b1896f34c1 100644 --- a/crates/ui-egui/src/widgets/tests.rs +++ b/crates/ui-egui/src/widgets/tests.rs @@ -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)| { + 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)); +}