Files
photocraft/crates/ui-egui/src/direction_tests.rs
T
ade5799165 Mirror the shell for right-to-left UI languages (DIST-220-4) (#3114)
* Mirror the shell for right-to-left UI languages (DIST-220-4)

Arabic, Hebrew, Persian and Urdu read right to left, but the window's
chrome was hard-wired left to right, so an RTL catalog (#2505) would
put Arabic labels into a left-to-right layout.

The reading direction now follows the language code
(i18n::dir_for_code: ar, ckb, dv, fa, he, ps, sd, ug, ur, yi), so a
catalog needs no extra setting and the LANGUAGES rows are unchanged.
In a right-to-left language the shell mirrors:

- the menu bar starts at the right with File; menus open under their
  title's right edge, and the arrow keys step through them in screen
  order
- the toolbox docks on the right, the panel dock and its rail on the
  left; tool flyouts open towards the canvas and their corner marks
  and the header chevrons follow
- the title, options and status bars read from the right, with the
  workspace controls and the Fit/progress group at the left end; the
  title and window-drag gap use the free span between them

New module direction.rs holds the helpers (start_panel/end_panel,
row/trailing layouts, horizontal_centered, the popup alignment). Left
to right, every change lays out as before.

No RTL catalog ships yet, so PHOTOCRAFT_UI_DIRECTION=rtl forces the
mirrored layout for review (read once, native only). Panel interiors,
dialogs, menu rows, submenus and document tabs stay left to right, and
egui still draws UI labels without Arabic shaping or bidi reordering;
docs/localization.md and the parity docs say so.

Adds Lucide's chevron-left (the status bar's info menu arrow points
left in RTL; Timeline's Previous frame button asked for it too and drew
the fallback square).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Localization docs: egui shapes Arabic words; what's missing is a font and bidi word order

egui 0.36 shapes right-to-left runs with HarfRust, so letters join within
a word. The gaps are an Arabic fallback font in the UI and paragraph bidi
(words are placed left to right; emilk/egui#8576, #8577).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: MathMattTech <334629259+MathMattTech@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:06:18 -04:00

144 lines
5.7 KiB
Rust

//! Right-to-left shell layout: the direction rules, and where the mirrored chrome lands in a
//! running shell. No right-to-left catalog is registered yet, so the shell tests force the
//! direction with `direction_scope`.
use super::*;
use crate::PhotocraftApp;
use crate::i18n::{self, Lang};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
#[test]
fn right_to_left_scripts_by_language_code() {
for code in ["ar", "AR", "ar-eg", "fa", "fa_IR", "he", "ur", "ckb", "ps", "yi"] {
assert_eq!(i18n::dir_for_code(code), Dir::Rtl, "{code}");
}
// `ku` alone is Kurmanji, written in Latin script; Sorani is `ckb`.
for code in ["en", "ja", "zh-hans", "pt-br", "ku", "arn", ""] {
assert_eq!(i18n::dir_for_code(code), Dir::Ltr, "{code}");
}
// No right-to-left catalog is registered yet, so the shell stays as it was.
for lang in Lang::all() {
assert_eq!(lang.dir(), Dir::Ltr, "{}", lang.code());
}
}
#[test]
fn direction_follows_the_language_unless_forced() {
let _lang = i18n::language_scope(Lang::EN);
let forced = direction_scope(Dir::Rtl);
assert!(is_rtl());
{
let _inner = direction_scope(Dir::Ltr);
assert!(!is_rtl());
}
assert!(is_rtl(), "the inner scope restores the outer one");
drop(forced);
assert_eq!(FORCED.get(), None);
}
#[test]
fn sides_and_rows_mirror() {
{
let _ltr = direction_scope(Dir::Ltr);
assert_eq!(row().main_dir(), egui::Direction::LeftToRight);
assert_eq!(trailing().main_dir(), egui::Direction::RightToLeft);
assert_eq!(below(), egui::RectAlign::BOTTOM_START);
}
let _rtl = direction_scope(Dir::Rtl);
assert_eq!(row().main_dir(), egui::Direction::RightToLeft);
assert_eq!(trailing().main_dir(), egui::Direction::LeftToRight);
assert_eq!(below(), egui::RectAlign::BOTTOM_END);
}
#[test]
fn env_values_parse_strictly() {
assert_eq!(parse_dir("rtl"), Some(Dir::Rtl));
assert_eq!(parse_dir(" RTL "), Some(Dir::Rtl));
assert_eq!(parse_dir("ltr"), Some(Dir::Ltr));
assert_eq!(parse_dir(""), None);
assert_eq!(parse_dir("ar"), None);
}
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ProMedium);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 64})).expect("new document");
app
});
h.run_steps(4);
h
}
/// Centre x of the widget labelled `label` (the first, when several are).
fn x_of(h: &Harness<'static, PhotocraftApp>, label: &str) -> f32 {
h.query_all_by_label(label).next().unwrap_or_else(|| panic!("`{label}` drawn")).rect().center().x
}
/// Left x of the text `text` where it is painted (for widgets without an accessible label).
fn text_x(h: &Harness<'static, PhotocraftApp>, text: &str) -> f32 {
fn find(shape: &egui::Shape, text: &str) -> Option<f32> {
match shape {
egui::Shape::Text(t) if t.galley.job.text == text => Some(t.pos.x),
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, text)),
_ => None,
}
}
h.output().shapes.iter().find_map(|s| find(&s.shape, text)).unwrap_or_else(|| panic!("`{text}` painted"))
}
fn dock_x(h: &Harness<'static, PhotocraftApp>) -> f32 {
let rects = crate::dock::last_rects(&h.ctx);
let (_, layers) = rects.iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("Layers docked");
layers.center().x
}
#[test]
fn the_shell_mirrors_in_a_right_to_left_ui() {
let _lang = i18n::language_scope(Lang::EN);
for dir in [Dir::Ltr, Dir::Rtl] {
let _dir = direction_scope(dir);
let h = harness();
let mid = 600.0;
let (file, help, toolbar, dock, workspace) = (x_of(&h, "File"), x_of(&h, "Help"), x_of(&h, "Toolbar"), dock_x(&h), text_x(&h, "Essentials"));
if dir == Dir::Rtl {
assert!(file > help, "File starts the menu bar at the right: {file} vs {help}");
assert!(toolbar > mid && dock < mid, "toolbox right, dock left: {toolbar}, {dock}");
assert!(workspace < help, "the workspace switcher is at the far (left) end: {workspace}");
} else {
assert!(file < help, "{file} vs {help}");
assert!(toolbar < mid && dock > mid, "{toolbar}, {dock}");
assert!(workspace > help, "{workspace}");
}
}
}
#[test]
fn arrow_keys_step_through_the_menu_bar_in_screen_order() {
let _lang = i18n::language_scope(Lang::EN);
for (dir, towards_edit) in [(Dir::Ltr, egui::Key::ArrowRight), (Dir::Rtl, egui::Key::ArrowLeft)] {
let _dir = direction_scope(dir);
let mut h = harness();
h.get_by_label("File").click();
h.run_steps(4);
assert!(h.query_by_label_contains("Undo").is_none(), "File is open, not Edit");
// Edit is File's neighbour on screen: to its right, or to its left in a right-to-left UI.
h.key_press(towards_edit);
h.run_steps(4);
assert!(h.query_by_label_contains("Undo").is_some(), "{dir:?}: {towards_edit:?} opens Edit");
}
}
#[test]
fn menus_open_under_the_title_s_reading_start_edge() {
let _lang = i18n::language_scope(Lang::EN);
let _dir = direction_scope(Dir::Rtl);
let mut h = harness();
let edit = h.get_by_label("Edit").rect();
h.get_by_label("Edit").click();
h.run_steps(4);
let undo = h.get_by_label_contains("Undo").rect();
assert!(undo.right() <= edit.right() + 1.0 && undo.right() > edit.right() - 40.0, "the menu hangs left from Edit's right edge: {undo:?} {edit:?}");
}