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.rs
new 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();
+}
