collective

Browse / github.com/storytold/cadcraft / patch

Hebrew names and text in reading order in the interface

Hebrew names and text in reading order in the interface Split out of #2034 (the AutoCAD-parity UI work), as asked in review: one theme per PR. #2034 stays open as the source; this part can be reviewed and merged on its own. Feature overview with screenshots (PDF): https://github.com/storytold/cadcraft/pull/2034#issuecomment-6436084948 ## What Hebrew (and other right-to-left) names and text read in reading order in the interface. Without this the text shaper turns a whole string containing a Hebrew letter around, English words, numbers and the file extension included (`שלום.dwg` showed as `gwd.םולש`). - `i18n::job` / `widget` / `paint` / `ltr`:

Status
open
Score
0.00
Adopters
0 other operators
Author
luminatiguy (imported from pull request #2317, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+158 −16 in 8 files
Review
pending
Shared
2026-10-28
Id
93f743fef1d53d3ce41088b8

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/chrome.rs crates/ui-egui/src/closing.rs crates/ui-egui/src/cmdline.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/palettes.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/rtl_names.rs

diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs--- a/crates/ui-egui/src/chrome.rs+++ b/crates/ui-egui/src/chrome.rs@@ -77,7 +77,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {             };             // Centred on the traffic lights' centre line (the row's middle): with the system font             // the capitals' middle is the text box's middle, so no optical nudge.-            let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim);+            let galley = ui.painter().layout_job(crate::i18n::job(&title, egui::FontId::proportional(13.0), t.text_dim));             let x = title_left(r.left(), r.width(), galley.size().x);             ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);         }@@ -262,7 +262,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { /// `text` laid out on one line no wider than `max_width`, ending in "…" when it had to be cut; /// and whether it was. fn elided(p: &egui::Painter, text: &str, font: egui::FontId, color: egui::Color32, max_width: f32) -> (std::sync::Arc<egui::Galley>, bool) {-    let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), font, color);+    let mut job = crate::i18n::job(text, font, color);     job.wrap = egui::text::TextWrapping { max_width: max_width.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };     let galley = p.layout_job(job);     let cut = galley.elided;diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs--- a/crates/ui-egui/src/closing.rs+++ b/crates/ui-egui/src/closing.rs@@ -208,7 +208,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) {         ui.set_width(360.0);         ui.label(RichText::new(crate::tl!("CADCraft")).strong());         ui.add_space(4.0);-        ui.label(crate::tf!("Save changes to {name}?", name = title));+        ui.label(crate::i18n::widget(ui, &crate::tf!("Save changes to {name}?", name = title), egui::TextStyle::Body));         ui.label(RichText::new(crate::tl!("Your changes will be lost if you don't save them.")).color(t.text_dim));         ui.add_space(10.0);         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs--- a/crates/ui-egui/src/cmdline.rs+++ b/crates/ui-egui/src/cmdline.rs@@ -453,6 +453,13 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize {     app.ui.history_lines.min(MAX_LINES).min(available) } +/// One line of command-line text laid out in reading order: text holding right-to-left letters+/// (a Hebrew text or layer name in a prompt, history line or typed value) would otherwise be+/// turned around as a whole by the shaper, English words and numbers included.+fn lay(p: &egui::Painter, s: String, font: egui::FontId, color: egui::Color32) -> std::sync::Arc<egui::Galley> {+    p.layout_job(crate::i18n::job(&s, font, color))+}+ /// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {     let t = Tokens::get();@@ -469,7 +476,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {         let hr = Rect::from_min_size(pos2(bar.left(), bar.top() - lh * lines.len() as f32 - 4.0), vec2(w, lh * lines.len() as f32 + 2.0));         p.rect_filled(hr, 3.0, t.cmd_history);         for (i, l) in lines.iter().enumerate() {-            p.text(pos2(hr.left() + 8.0, hr.top() + 1.0 + lh * i as f32), egui::Align2::LEFT_TOP, l, crate::theme::small(), t.text_dim);+            crate::i18n::paint(+                &p,+                pos2(hr.left() + 8.0, hr.top() + 1.0 + lh * i as f32),+                egui::Align2::LEFT_TOP,+                l,+                crate::theme::small(),+                t.text_dim,+            );         }     }     p.rect_filled(bar, 3.0, t.cmd_bg);@@ -483,24 +497,24 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {         Some(pr) => {             // The grip modes name themselves ("** STRETCH **…").             let name = app.session.running.as_ref().filter(|r| r.id != "grip").map(|r| r.id.to_ascii_uppercase() + " ").unwrap_or_default();-            let g = p.layout_no_wrap(name, font.clone(), t.text_faint);+            let g = lay(&p, name, font.clone(), t.text_faint);             let gw = g.size().x;             p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint);             x += gw;             // A LISP `get…` prompt shows as the routine wrote it (no keywords, default or colon added).             let message = if pr.raw { pr.display() } else { crate::i18n::t(&pr.message).to_string() };-            let g = p.layout_no_wrap(message, font.clone(), t.text);+            let g = lay(&p, message, font.clone(), t.text);             let gw = g.size().x;             p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);             x += gw;             if !pr.raw && !pr.keywords.is_empty() {-                let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text);+                let g = lay(&p, if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;                 let kws = pr.keywords.clone();                 for (i, k) in kws.iter().enumerate() {-                    let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword);+                    let g = lay(&p, k.clone(), font.clone(), t.cmd_keyword);                     let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0));                     let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click());                     if resp.hovered() {@@ -513,25 +527,25 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {                         let _ = app.session.input(Input::Keyword(k.clone()));                     }                     if i + 1 < kws.len() {-                        let g = p.layout_no_wrap("/".into(), font.clone(), t.text);+                        let g = lay(&p, "/".into(), font.clone(), t.text);                         let gw = g.size().x;                         p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                         x += gw;                     }                 }-                let g = p.layout_no_wrap("]".into(), font.clone(), t.text);+                let g = lay(&p, "]".into(), font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;             }             if let Some(d) = pr.default.as_ref().filter(|_| !pr.raw) {-                let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text);+                let g = lay(&p, format!(" <{d}>"), font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;             }             if !pr.raw {-                let g = p.layout_no_wrap(": ".into(), font.clone(), t.text);+                let g = lay(&p, ": ".into(), font.clone(), t.text);                 let gw = g.size().x;                 p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);                 x += gw;@@ -551,7 +565,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {             }         }     }-    let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text);+    let g = lay(&p, app.cmd.buffer.clone(), font.clone(), t.text);     let gw = g.size().x;     p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text);     let mut caret_x = x + gw;@@ -708,7 +722,7 @@ fn history_window(app: &CadApp, ui: &mut egui::Ui, r: Rect) {             for l in log.get(rows).into_iter().flatten() {                 let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), LINE_H), Sense::hover());                 let p = ui.painter().with_clip_rect(row.intersect(ui.clip_rect()));-                p.text(pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim);+                crate::i18n::paint(&p, pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim);             }         },     );diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -401,6 +401,105 @@ macro_rules! tf { #[cfg(test)] mod tests; +/// Whether `s` holds right-to-left letters, so it needs [`job`] to be drawn in reading order+/// (the fonts crate's test, which knows every right-to-left script).+pub fn has_rtl(s: &str) -> bool {+    cadcraft_fonts::bidi::has_rtl(s)+}++/// Whether `c` attaches to the letter before it (combining marks, Hebrew points, joiners).+fn is_mark(c: char) -> bool {+    matches!(u32::from(c), 0x0300..=0x036F | 0x0591..=0x05BD | 0x05BF | 0x05C1..=0x05C2 | 0x05C4..=0x05C5 | 0x05C7 | 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x200C..=0x200D | 0xFE00..=0xFE0F | 0xFE20..=0xFE2F)+}++/// The pieces of `s` in display order for a left-to-right line, each to be shaped on its own:+/// left-to-right runs whole, right-to-left runs letter by letter (with their marks) reversed and+/// brackets mirrored. The text shaper turns any piece holding a right-to-left letter around, and+/// with a font that covers Latin and Hebrew alike (Segoe UI) it would turn the whole name+/// (extension included) around; one letter per piece leaves nothing to turn.+fn display_pieces(s: &str) -> Vec<String> {+    let chars: Vec<char> = s.chars().collect();+    let mut out = Vec::new();+    for (r, rtl) in cadcraft_fonts::bidi::visual_runs(&chars, Some(false)) {+        let run = chars.get(r).unwrap_or_default();+        if !rtl {+            out.push(run.iter().collect());+            continue;+        }+        let mut clusters: Vec<String> = Vec::new();+        for c in run {+            match clusters.last_mut() {+                Some(last) if is_mark(*c) => last.push(*c),+                _ => clusters.push(cadcraft_fonts::bidi::mirror(*c).to_string()),+            }+        }+        out.extend(clusters.into_iter().rev());+    }+    out+}++/// `s` (a file, drawing, block or layer name…) laid out on one line in reading order: right-to-left+/// runs read as they do in the file manager, `שלום.dwg` with the extension on the right.+pub fn job(s: &str, font: egui::FontId, color: egui::Color32) -> egui::text::LayoutJob {+    let mut job = egui::text::LayoutJob::simple_singleline(s.to_string(), font, color);+    // The text shaper orders right-to-left runs itself (UAX #9); the line reads left to right, as+    // the interface does, so a Hebrew name keeps its extension on the right.+    if has_rtl(s) {+        job.direction = egui::epaint::text::TextDirection::LeftToRight;+    }+    job+}++/// `s` as widget text (button, label, menu item) in reading order; the widget's own colour.+pub fn widget(ui: &egui::Ui, s: &str, style: egui::TextStyle) -> egui::WidgetText {+    if !has_rtl(s) {+        return s.to_string().into();+    }+    egui::WidgetText::from(job(s, style.resolve(ui.style()), egui::Color32::PLACEHOLDER))+}++/// Paints `s` in reading order, anchored like [`egui::Painter::text`]; returns its rectangle.+pub fn paint(p: &egui::Painter, pos: egui::Pos2, anchor: egui::Align2, s: &str, font: egui::FontId, color: egui::Color32) -> egui::Rect {+    let galley = p.layout_job(job(s, font, color));+    let rect = anchor.anchor_size(pos, galley.size());+    p.galley(rect.min, galley, color);+    rect+}++/// `s` in display order as one plain string, for tests and logs (what [`job`] shows).+pub fn ltr(s: &str) -> std::borrow::Cow<'_, str> {+    if !has_rtl(s) {+        return std::borrow::Cow::Borrowed(s);+    }+    std::borrow::Cow::Owned(display_pieces(s).concat())+}++#[cfg(test)]+mod ltr_tests {+    #[test]+    fn hebrew_file_names_keep_the_extension_on_the_right() {+        assert_eq!(super::ltr("שלום.dwg"), "םולש.dwg");+        assert_eq!(super::ltr("plan 2.dwg"), "plan 2.dwg");+        assert_eq!(super::ltr("תוכנית קומה 1.dwg"), "1 המוק תינכות.dwg");+        // Right-to-left scripts beyond Hebrew and Arabic (Adlam) too (#2317 review).+        assert!(super::has_rtl("\u{1E900}\u{1E901}.dwg"));+        assert_eq!(super::ltr("\u{1E900}\u{1E901}.dwg"), "\u{1E901}\u{1E900}.dwg");+    }++    #[test]+    fn every_right_to_left_letter_is_shaped_alone() {+        // Pieces: each Hebrew letter by itself, the extension whole, so the shaper (which turns+        // any piece holding Hebrew around) cannot reverse the name or its extension.+        assert_eq!(super::display_pieces("שלום.dwg"), ["ם", "ו", "ל", "ש", ".dwg"]);+        assert_eq!(super::display_pieces("plan.dwg"), ["plan.dwg"]);+        // Points stay on their letter.+        assert_eq!(super::display_pieces("שָׁלוֹם"), ["ם", "וֹ", "ל", "שָׁ"]);+        // The shaper orders the runs itself, on a left-to-right line.+        let job = super::job("שלום.dwg", egui::FontId::proportional(12.0), egui::Color32::WHITE);+        assert_eq!(job.direction, egui::epaint::text::TextDirection::LeftToRight);+    }+}+ /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are. fn name_sort_key(name: &str) -> String {diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -581,7 +581,7 @@ pub(crate) fn shortcut_label(s: &str, mac: bool) -> String { pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String>) {     match e {         Entry::Item { label, id, shortcut, enabled } => {-            let mut b = egui::Button::new(crate::i18n::t(label));+            let mut b = egui::Button::new(crate::i18n::widget(ui, crate::i18n::t(label), egui::TextStyle::Button));             if let Some(s) = shortcut {                 b = b.shortcut_text(shortcut_label(s, ui.ctx().os().is_mac()));             }diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs--- a/crates/ui-egui/src/palettes.rs+++ b/crates/ui-egui/src/palettes.rs@@ -556,7 +556,18 @@ fn edit_field(ui: &mut egui::Ui, id: egui::Id, value: String) -> Option<String>     let mut buf = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| value.clone());     let w = ui.available_width() - 10.0;     let te = id.with("te");-    let resp = ui.add_sized([w, 20.0], egui::TextEdit::singleline(&mut buf).id(te).font(crate::theme::body()));+    let focused = ui.memory(|m| m.has_focus(te));+    // Unfocused, a value holding right-to-left letters (a Hebrew text) shows in reading order;+    // the shaper would otherwise turn it around as a whole, English words and numbers included.+    let mut reading_order = |ui: &egui::Ui, text: &dyn egui::TextBuffer, _wrap: f32| {+        let job = crate::i18n::job(text.as_str(), crate::theme::body(), ui.visuals().text_color());+        ui.fonts_mut(|f| f.layout_job(job))+    };+    let mut edit = egui::TextEdit::singleline(&mut buf).id(te).font(crate::theme::body());+    if !focused && crate::i18n::has_rtl(&value) {+        edit = edit.layouter(&mut reading_order);+    }+    let resp = ui.add_sized([w, 20.0], edit);     let mut out = None;     // Keyboard focus from memory: `Response::has_focus` is false while the window itself is     // unfocused, which dropped the typed text every frame.diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -24,6 +24,7 @@ mod parameters_rename; mod pickdrag; mod properties_units; mod rapid_clicks;+mod rtl_names; mod selection_cycling; mod shared_controls; mod shortcut_menus;diff --git a/crates/ui-egui/tests/it/rtl_names.rs b/crates/ui-egui/tests/it/rtl_names.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/rtl_names.rs@@ -0,0 +1,17 @@+//! A Hebrew file name reads as it does in the file manager: the letters right to left, the+//! extension on the right (`שלום.dwg`), whatever the text shaper does with right-to-left runs.++#[test]+fn hebrew_file_name_is_laid_out_in_reading_order() {+    let ctx = egui::Context::default();+    let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});+    out.textures_delta.clear();+    let job = cadcraft_ui_egui::i18n::job("שלום.dwg", egui::FontId::proportional(14.0), egui::Color32::WHITE);+    let galley = ctx.fonts_mut(|f| f.layout_job(job));+    let mut glyphs: Vec<(f32, char)> = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.pos.x, g.chr))).collect();+    glyphs.sort_by(|a, b| a.0.total_cmp(&b.0));+    let shown: String = glyphs.iter().map(|(_, c)| c).collect();+    // Left to right on screen: the last letter first, the extension last and not reversed.+    assert_eq!(shown, "םולש.dwg");+    ctx.tex_manager().write().take_delta().clear();+}