From e3fef2a9c3be69cf25b6ff7f7ae81aa97df70818 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Wed, 11 Nov 2026 10:19:51 -0300 Subject: [PATCH] backstage: right-align template titles and thumbnails in RTL (#6151) (#6163) --- crates/ui-egui/src/backstage.rs | 7 +- crates/ui-egui/src/backstage/tests.rs | 136 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f211004feb..d2163e509e 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -321,7 +321,9 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color()); let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption)); - let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0)); + let flows = crate::rtl::flows(ui); + let r_min_x = if flows { tile.max.x - 150.0 } else { tile.min.x }; + let r = Rect::from_min_size(pos2(r_min_x, tile.min.y), vec2(150.0, 194.0)); ui.painter().rect( r, 2.0, @@ -352,7 +354,8 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { if let Some(h) = tex { ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); } - ui.painter().galley(pos2(r.min.x, r.max.y), galley, ui.visuals().text_color()); + let x = if flows { r.max.x - galley.size().x } else { r.min.x }; + ui.painter().galley(pos2(x, r.max.y), galley, ui.visuals().text_color()); if resp.clicked() { let _ = app.run("file.new", json!({"template": template})); app.ui.backstage = false; diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 3003642b9b..14592fb9f9 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -1156,3 +1156,139 @@ fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() { assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}"); assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}"); } + +/// Recursively collects bounding rectangles and text entries from egui shapes. +fn collect_shapes(shape: &egui::Shape, rects: &mut Vec, texts: &mut Vec<(String, egui::Pos2, f32)>) { + match shape { + egui::Shape::Rect(r) => rects.push(r.rect), + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.pos, t.galley.size().x)), + egui::Shape::Vec(v) => { + for s in v { + collect_shapes(s, rects, texts); + } + } + _ => {} + } +} + +/// #6151: in an RTL interface (Arabic), template titles on the Start/Home page +/// and New page are right-aligned to their preview thumbnails rather than left-aligned. +#[test] +fn rtl_template_titles_are_right_aligned() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.ui.language = "ar".into(); + a.ui.backstage = true; + a.ui.backstage_page = "home".into(); + let ctx = egui::Context::default(); + let mut last_shapes = Vec::new(); + for frame in 0..4 { + let input = egui::RawInput { + time: Some(f64::from(frame) * 0.1), + screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + a.logic(&ctx.clone()); + a.ui(ui); + }); + out.textures_delta.clear(); + if frame == 3 { + last_shapes = out.shapes; + } + } + + let mut rects = Vec::new(); + let mut texts = Vec::new(); + for s in &last_shapes { + collect_shapes(&s.shape, &mut rects, &mut texts); + } + + let blank_caption = crate::i18n::tc("template", "Blank document"); + let (_, pos, width) = texts.iter().find(|(t, _, _)| t == blank_caption).expect("blank template caption drawn"); + let thumbnail = rects + .iter() + .find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - pos.y).abs() < 1.0) + .expect("blank template thumbnail rect"); + + let text_right = pos.x + width; + assert!( + (thumbnail.max.x - text_right).abs() < 1.0, + "template caption should be right-aligned with thumbnail in RTL: thumbnail.max.x={}, text_right={}, text_left={}, caption={}", + thumbnail.max.x, + text_right, + pos.x, + blank_caption, + ); + + // Verify the New page through the normal UI path in Arabic as well (#6151). + a.ui.backstage_page = "new".into(); + let mut new_out = ctx.run_ui( + egui::RawInput { + time: Some(0.4), + screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))), + ..Default::default() + }, + |ui| { + a.logic(&ctx.clone()); + a.ui(ui); + }, + ); + new_out.textures_delta.clear(); + let mut new_rects = Vec::new(); + let mut new_texts = Vec::new(); + for s in &new_out.shapes { + collect_shapes(&s.shape, &mut new_rects, &mut new_texts); + } + for (label, _) in super::TEMPLATES { + let caption = crate::i18n::tc("template", label); + if let Some((_, new_pos, new_width)) = new_texts.iter().find(|(t, _, _)| t == caption) { + let right = new_pos.x + new_width; + let has_matching_thumbnail = new_rects.iter().any(|r| { + (r.width() - 150.0).abs() < 1.0 + && (r.height() - 194.0).abs() < 1.0 + && (r.max.y - new_pos.y).abs() < 1.0 + && (r.max.x - right).abs() < 1.0 + }); + assert!( + has_matching_thumbnail, + "New page: caption '{caption}' must be right-aligned with its 150pt thumbnail (right={right}, y={})", + new_pos.y + ); + } + } + + // Also verify that a template tile with a wide caption (>150 pt) aligns both the thumbnail + // and caption to the right edge in RTL. + let wide_label = "A very long template title that exceeds 150 points width"; + let mut wide_out = ctx.run_ui( + egui::RawInput { + time: Some(0.5), + screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))), + ..Default::default() + }, + |ui| { + crate::rtl::root(ui, |ui| { + super::template_tile(ui, &mut a, wide_label, "sample"); + }); + }, + ); + wide_out.textures_delta.clear(); + let mut wide_rects = Vec::new(); + let mut wide_texts = Vec::new(); + for s in &wide_out.shapes { + collect_shapes(&s.shape, &mut wide_rects, &mut wide_texts); + } + let (_, wide_pos, wide_width) = wide_texts.iter().find(|(t, _, _)| t == wide_label).expect("wide template caption drawn"); + assert!(*wide_width > 150.0, "wide caption should exceed 150 points: {wide_width}"); + let wide_thumbnail = wide_rects + .iter() + .find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - wide_pos.y).abs() < 1.0) + .expect("wide template thumbnail rect"); + let wide_text_right = wide_pos.x + wide_width; + assert!( + (wide_thumbnail.max.x - wide_text_right).abs() < 1.0, + "wide template caption and thumbnail should share the same right edge in RTL: thumb.max.x={}, text_right={}", + wide_thumbnail.max.x, + wide_text_right, + ); +}