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
@@ -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;@@ -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| {@@ -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); } }, );@@ -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 {@@ -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())); }@@ -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.@@ -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;new file mode 100644@@ -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();+}