mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 19:07:06 -06:00
* 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>
144 lines
5.7 KiB
Rust
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:?}");
|
|
}
|