diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index b89665ad3..c244880b0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -353,8 +353,10 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { 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 display_name = crate::canvas::bidi_display(&name); + let display_p = crate::canvas::bidi_display(&p); + ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, display_name.as_ref(), medium(13.0), t.text); + ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, display_p.as_ref(), regular(11.0), t.text_dim); if resp.clicked() { let _ = app.run("file.open", json!({"path": p})); app.ui.backstage = false; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4eca0d304..255a18551 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1841,10 +1841,24 @@ fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32, } /// Does `text` hold right-to-left letters (Arabic, Hebrew, Syriac, Thaana, N'Ko …)? -fn has_rtl(text: &str) -> bool { +pub(crate) fn has_rtl(text: &str) -> bool { text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL)) } +/// Put `text` into visual display order for egui when it contains right-to-left characters. +/// Uses an LTR base paragraph direction so left-aligned UI elements (recent files, paths) preserve +/// trailing extensions and numbers in their proper visual order. +pub(crate) fn bidi_display(text: &str) -> std::borrow::Cow<'_, str> { + if !has_rtl(text) { + return std::borrow::Cow::Borrowed(text); + } + let info = unicode_bidi::BidiInfo::new(text, Some(unicode_bidi::Level::ltr())); + match info.paragraphs.first() { + Some(p) => std::borrow::Cow::Owned(visual_row(&info, p, text, 0..text.len())), + None => std::borrow::Cow::Borrowed(text), + } +} + /// The row `line` of paragraph `p` of `para` in display order, for egui: the bidi runs left to /// right as they are shown, and inside a right-to-left run its words reversed. The letters of each /// word stay in logical order, because egui's shaper already runs an Arabic word right to left. @@ -3622,6 +3636,13 @@ mod tests { assert_eq!(visual_row(&info, p, text, 0..text.len() + 5), ""); } + #[test] + fn bidi_display_preserves_extensions_and_numbers() { + assert_eq!(bidi_display("report.docx").as_ref(), "report.docx"); + assert_eq!(bidi_display("نواقص 2026.docx").as_ref(), "2026 نواقص.docx"); + assert_eq!(bidi_display("مذكرة داخلية.docx").as_ref(), "داخلية مذكرة.docx"); + } + #[test] fn a_balloon_edits_its_comment() { let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 662516fbd..7f88cf6a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -472,6 +472,8 @@ pub struct WordApp { /// The document (id, revision) whose comment text [`WordApp::ensure_comment_fonts`] last /// looked at. comment_fonts_for: Option<(u64, u64)>, + /// The list of recent document paths [`WordApp::ensure_recent_fonts`] last looked at. + recent_fonts_for: Vec, fonts_frames: u32, /// The effective interface theme last installed in egui ([`theme::apply`]). applied_theme: Option, @@ -599,6 +601,7 @@ impl WordApp { fonts_system_cjk: false, want_system_cjk: false, comment_fonts_for: None, + recent_fonts_for: Vec::new(), fonts_frames: 0, applied_theme: None, native_scale: 1.0, @@ -1711,6 +1714,32 @@ impl WordApp { } } + /// Recent documents in Backstage (and the Start screen) show file names and paths with the + /// interface faces. Borrow document fallback faces for characters in non-Latin scripts (#2067), + /// as the caption and comments do. + fn ensure_recent_fonts(&mut self) { + if self.recent_fonts_for.as_slice() == self.ui.recent.as_slice() { + return; + } + self.recent_fonts_for = self.ui.recent.clone(); + for p in &self.ui.recent { + if p.chars().any(wordcraft_fonts::is_cjk) { + self.want_system_cjk = true; + } + let new = theme::faces_for_preedit(p, &self.canvas.ime_faces, theme::caption_char_covered, |c| { + wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX) + }); + if !new.is_empty() { + for face in new { + if !self.canvas.ime_faces.iter().any(|f| f.id() == face.id()) { + self.canvas.ime_faces.push(face); + } + } + self.styled = false; + } + } + } + /// Document title for the title bar (and the suggested file name). An untitled document is /// named in the interface language, as `Document1` is in English. pub fn title_stem(&self) -> String { @@ -1758,6 +1787,7 @@ impl WordApp { // in this frame (dialog, drop, control channel) is picked up again at the end. self.ensure_caption_fonts(); self.ensure_comment_fonts(); + self.ensure_recent_fonts(); self.install_ui_fonts(ctx, lang); if let Some(answer) = self.desktop_theme.take_change() { self.desktop_dark = Some(answer); @@ -1868,6 +1898,7 @@ impl WordApp { // The open above may have changed the file name the caption draws. self.ensure_caption_fonts(); self.ensure_comment_fonts(); + self.ensure_recent_fonts(); self.install_ui_fonts(ctx, lang); self.report_dirty(); } @@ -2561,6 +2592,32 @@ mod tests { assert_eq!(arabic.canvas.ime_faces.len(), n, "the face is added once"); } + /// Recent documents in Backstage and the Start screen draw file names and paths with the + /// interface faces, which have no Arabic, Hebrew, Thai …: a recent document borrows a + /// document fallback face that covers it (#2067). + #[test] + fn recent_documents_borrow_fallback_faces() { + let ctx = egui::Context::default(); + let frame = |app: &mut WordApp| { + ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); + }; + let mut latin = app(); + latin.ui.recent.push("/home/ice/report.docx".into()); + frame(&mut latin); + assert!(latin.canvas.ime_faces.is_empty(), "a Latin file name needs no extra face"); + + let mut arabic = app(); + arabic.ui.recent.push("/home/ice/نواقص 2026.docx".into()); + frame(&mut arabic); + let letter = 'ن'; + if wordcraft_fonts::FontDb::global().fallback_for(letter, u32::MAX).is_some_and(|f| f.data().len() <= 16 << 20) { + assert!(arabic.canvas.ime_faces.iter().any(|f| f.covers(letter)), "the recent list borrows a face that covers the file name"); + } + let n = arabic.canvas.ime_faces.len(); + frame(&mut arabic); + assert_eq!(arabic.canvas.ime_faces.len(), n, "the face is added once"); + } + fn app() -> WordApp { WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) }