mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: MathMattTech <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
16 files changed
+430
-67
No files matched your search
+2
-2
@@ -20,8 +20,8 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
|
||||
| Simplified Chinese | `zh-hans` | 100% | partial |
|
||||
| Spanish | `es` | 100% | partial |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 0% (no RTL UI) | none |
|
||||
| Arabic | `ar` | 0% (RTL shell only) | none |
|
||||
| French | `fr` | 100% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% | partial |
|
||||
| Indonesian | `id` | 100% | partial |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -159,7 +159,8 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
icons::paint(ui, r, "chevron-right", 11.0, t.text_dim);
|
||||
// Points along the bar, away from the zoom field.
|
||||
icons::paint(ui, r, if crate::direction::is_rtl() { "chevron-left" } else { "chevron-right" }, 11.0, t.text_dim);
|
||||
let resp = resp.on_hover_text(tl!("Show"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
crate::widgets::style_spectrum_popup_menu(ui);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Right-to-left shell layout (Arabic, Hebrew, Persian, Urdu).
|
||||
//!
|
||||
//! The direction follows the UI language ([`crate::i18n::Lang::dir`]). In a right-to-left
|
||||
//! language the window's chrome mirrors, as in right-to-left macOS and Windows apps: the menu
|
||||
//! bar starts at the right, the toolbox docks on the right, the panel dock and its rail on the
|
||||
//! left, and the title and status bars read from the right. Panel interiors, dialogs, document
|
||||
//! tabs and the canvas keep their left-to-right layout for now (`docs/localization-parity.md`).
|
||||
//!
|
||||
//! egui has no global direction, so call sites choose sides and rows through these helpers.
|
||||
//! Rows nested in a [`row`] follow it on their own: egui starts a `ui.horizontal*` row from the
|
||||
//! right when its parent layout runs right-to-left.
|
||||
//!
|
||||
//! `PHOTOCRAFT_UI_DIRECTION=rtl` (or `ltr`) forces a direction whatever the language, so the
|
||||
//! mirrored layout can be checked before a right-to-left catalog is installed. It is read once
|
||||
//! per process; the web build has no environment and always follows the language.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{Align, Layout};
|
||||
|
||||
pub use crate::i18n::Dir;
|
||||
|
||||
thread_local! {
|
||||
// Like the drawing language, per thread: test harnesses run side by side.
|
||||
static FORCED: Cell<Option<Dir>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The direction the UI is drawn in on this thread.
|
||||
pub fn current() -> Dir {
|
||||
FORCED.get().or_else(env_override).unwrap_or_else(|| crate::i18n::current().dir())
|
||||
}
|
||||
|
||||
/// Is the UI drawn right-to-left?
|
||||
pub fn is_rtl() -> bool {
|
||||
current() == Dir::Rtl
|
||||
}
|
||||
|
||||
/// Draw in `dir` whatever the language until the guard drops on this thread (tests).
|
||||
#[must_use]
|
||||
pub fn direction_scope(dir: Dir) -> impl Drop {
|
||||
struct Restore {
|
||||
previous: Option<Dir>,
|
||||
_thread: std::marker::PhantomData<std::rc::Rc<()>>,
|
||||
}
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
FORCED.set(self.previous);
|
||||
}
|
||||
}
|
||||
let restore = Restore { previous: FORCED.get(), _thread: std::marker::PhantomData };
|
||||
FORCED.set(Some(dir));
|
||||
restore
|
||||
}
|
||||
|
||||
fn env_override() -> Option<Dir> {
|
||||
static ENV: OnceLock<Option<Dir>> = OnceLock::new();
|
||||
*ENV.get_or_init(|| parse_dir(&std::env::var("PHOTOCRAFT_UI_DIRECTION").ok()?))
|
||||
}
|
||||
|
||||
/// `rtl` / `ltr`, any case; anything else (including empty) follows the language.
|
||||
fn parse_dir(value: &str) -> Option<Dir> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"rtl" => Some(Dir::Rtl),
|
||||
"ltr" => Some(Dir::Ltr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A side panel on the reading-start side: left, or right in a right-to-left UI (the toolbox).
|
||||
pub fn start_panel(id: impl Into<egui::Id>) -> egui::Panel {
|
||||
if is_rtl() { egui::Panel::right(id) } else { egui::Panel::left(id) }
|
||||
}
|
||||
|
||||
/// A side panel on the reading-end side: right, or left in a right-to-left UI (the dock).
|
||||
pub fn end_panel(id: impl Into<egui::Id>) -> egui::Panel {
|
||||
if is_rtl() { egui::Panel::left(id) } else { egui::Panel::right(id) }
|
||||
}
|
||||
|
||||
/// A vertically centred row in reading order.
|
||||
pub fn row() -> Layout {
|
||||
if is_rtl() { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) }
|
||||
}
|
||||
|
||||
/// A vertically centred row that starts from the reading end: for groups pinned to the far end
|
||||
/// of a bar, such as the title bar's controls or the status bar's Fit button.
|
||||
pub fn trailing() -> Layout {
|
||||
if is_rtl() { Layout::left_to_right(Align::Center) } else { Layout::right_to_left(Align::Center) }
|
||||
}
|
||||
|
||||
/// [`egui::Ui::horizontal_centered`] in reading order: a row filling the space left, with its
|
||||
/// items centred vertically, starting from the right in a right-to-left UI.
|
||||
pub fn horizontal_centered<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> egui::InnerResponse<R> {
|
||||
let size = ui.available_size_before_wrap();
|
||||
ui.allocate_ui_with_layout(size, row(), add)
|
||||
}
|
||||
|
||||
/// The popup alignment for a menu opening below its button: under its reading-start edge.
|
||||
pub fn below() -> egui::RectAlign {
|
||||
if is_rtl() { egui::RectAlign::BOTTOM_END } else { egui::RectAlign::BOTTOM_START }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "direction_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).
|
||||
//!
|
||||
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests (parse,
|
||||
//! placeholders, plural forms) pick it up from the registry.
|
||||
//! placeholders, plural forms) pick it up from the registry. A right-to-left language (Arabic,
|
||||
//! Hebrew, Persian, Urdu, see [`dir_for_code`]) also mirrors the window's chrome
|
||||
//! ([`crate::direction`]).
|
||||
//!
|
||||
//! # Looking strings up
|
||||
//! - [`tr`]: a plain string. [`tr_ctx`]: when one English word needs different translations.
|
||||
@@ -46,6 +48,23 @@ pub struct LangInfo {
|
||||
catalog: OnceLock<Catalog>,
|
||||
}
|
||||
|
||||
/// A script's reading direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dir {
|
||||
Ltr,
|
||||
Rtl,
|
||||
}
|
||||
|
||||
/// Languages written right to left, by primary subtag: Arabic, Sorani Kurdish, Dhivehi, Persian,
|
||||
/// Hebrew, Pashto, Sindhi, Uyghur, Urdu, Yiddish.
|
||||
const RTL_LANGUAGES: [&str; 10] = ["ar", "ckb", "dv", "fa", "he", "ps", "sd", "ug", "ur", "yi"];
|
||||
|
||||
/// The reading direction of a language code (`ar`, `fa-ir`, `he`; any case).
|
||||
pub fn dir_for_code(code: &str) -> Dir {
|
||||
let primary = code.split(['-', '_']).next().unwrap_or("");
|
||||
if RTL_LANGUAGES.iter().any(|l| l.eq_ignore_ascii_case(primary)) { Dir::Rtl } else { Dir::Ltr }
|
||||
}
|
||||
|
||||
fn plural_one_other(n: u64) -> usize {
|
||||
usize::from(n != 1)
|
||||
}
|
||||
@@ -214,6 +233,11 @@ impl Lang {
|
||||
self.0.complete_menus
|
||||
}
|
||||
|
||||
/// The language's reading direction.
|
||||
pub fn dir(self) -> Dir {
|
||||
dir_for_code(self.0.code)
|
||||
}
|
||||
|
||||
fn catalog(self) -> &'static Catalog {
|
||||
self.0.catalog()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")),
|
||||
("check", include_bytes!("../../../assets/icons/check.svg")),
|
||||
("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")),
|
||||
("chevron-left", include_bytes!("../../../assets/icons/chevron-left.svg")),
|
||||
("chevron-right", include_bytes!("../../../assets/icons/chevron-right.svg")),
|
||||
("chevron-up", include_bytes!("../../../assets/icons/chevron-up.svg")),
|
||||
("chevrons-left", include_bytes!("../../../assets/icons/chevrons-left.svg")),
|
||||
|
||||
@@ -60,6 +60,7 @@ pub mod delete_layer_prompt;
|
||||
pub mod dialog_blend_ui;
|
||||
pub mod dialogs;
|
||||
pub mod direct_select;
|
||||
pub mod direction;
|
||||
pub mod discard_ui;
|
||||
pub mod distort_ui;
|
||||
pub mod doc_props_ui;
|
||||
|
||||
@@ -1024,7 +1024,9 @@ impl ItemCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the menu bar; returns the right edge of the last menu title (the bar itself fills the row).
|
||||
/// Draws the menu bar; returns the inner edge of the menu titles, facing the rest of the title bar
|
||||
/// (the right edge of the last title, or its left edge in a right-to-left UI, where File is at the
|
||||
/// right). The bar itself fills the row.
|
||||
pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
// Built only while a menu is open: every item's enabled/checked state scales with the
|
||||
// document (layer lookups), which cost milliseconds per frame on large layouts (#125). While
|
||||
@@ -1032,7 +1034,8 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
let kept = std::cell::Cell::new(app.menu_cache.take());
|
||||
let built: std::cell::OnceCell<ItemCache> = std::cell::OnceCell::new();
|
||||
let app_ref: &PhotocraftApp = app;
|
||||
let mut right = ui.cursor().left();
|
||||
let rtl = crate::direction::is_rtl();
|
||||
let mut edge = if rtl { ui.cursor().right() } else { ui.cursor().left() };
|
||||
let lang = crate::i18n::current();
|
||||
let mut clicked: Option<String> = None;
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
@@ -1045,6 +1048,9 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.text_dim);
|
||||
v.widgets.hovered.bg_stroke = egui::Stroke::NONE;
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
// egui lays the bar out left to right; a right-to-left UI starts it at the right.
|
||||
let mut mirrored = rtl.then(|| ui.new_child(egui::UiBuilder::new().layout(crate::direction::row())));
|
||||
let ui = mirrored.as_mut().unwrap_or(ui);
|
||||
ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0);
|
||||
nav.bar_bottom = Some(ui.max_rect().bottom());
|
||||
let mut buttons = Vec::with_capacity(TOP_MENUS.len());
|
||||
@@ -1070,6 +1076,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
config.close_behavior
|
||||
};
|
||||
egui::Popup::menu(&title)
|
||||
.align(crate::direction::below())
|
||||
.open_memory(open)
|
||||
.close_behavior(close)
|
||||
.style(config.style.clone())
|
||||
@@ -1100,9 +1107,13 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
});
|
||||
buttons.push(title);
|
||||
}
|
||||
right = buttons.iter().map(|b| b.rect.right()).fold(right, f32::max);
|
||||
edge = if rtl { buttons.iter().map(|b| b.rect.left()).fold(edge, f32::min) } else { buttons.iter().map(|b| b.rect.right()).fold(edge, f32::max) };
|
||||
switch_on_hover(ui.ctx(), &buttons);
|
||||
let tops: Vec<egui::Id> = buttons.iter().map(egui::Popup::default_response_id).collect();
|
||||
// ← and → step to the menu on that side, so in screen order.
|
||||
let mut tops: Vec<egui::Id> = buttons.iter().map(egui::Popup::default_response_id).collect();
|
||||
if rtl {
|
||||
tops.reverse();
|
||||
}
|
||||
nav.keys(ui.ctx(), &tops, &mut clicked);
|
||||
});
|
||||
});
|
||||
@@ -1121,7 +1132,7 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
right
|
||||
edge
|
||||
}
|
||||
|
||||
fn press_gesture_id() -> egui::Id {
|
||||
|
||||
@@ -84,13 +84,17 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Two columns when the header chevron asks for them; a column that doesn't fit scrolls.
|
||||
let double = app.ui.panels.toolbar_double;
|
||||
let w = if double { w1 + bx + 2.0 } else { w1 };
|
||||
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
crate::direction::start_panel("toolbar")
|
||||
.resizable(false)
|
||||
.exact_size(w)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8)))
|
||||
.show(ui, |ui| {
|
||||
if t.pro {
|
||||
// Photoshop's toolbar header chevrons switch between one and two columns.
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click());
|
||||
icons::paint(ui, cr, if double { "chevrons-left" } else { "chevrons-right" }, 11.0, if resp.hovered() { t.text } else { t.text_faint });
|
||||
// The chevrons point the way the toolbar grows or shrinks: towards the canvas.
|
||||
let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") };
|
||||
icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar")));
|
||||
if resp.clicked() {
|
||||
app.ui.panels.toolbar_double = !app.ui.panels.toolbar_double;
|
||||
@@ -189,10 +193,15 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id))
|
||||
&& open_key == key
|
||||
{
|
||||
let area = egui::Area::new(key.with("flyout"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(anchor.right_top() + vec2(6.0, 0.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
// Beside the tool, towards the canvas.
|
||||
let (pivot, pos) = if crate::direction::is_rtl() {
|
||||
(egui::Align2::RIGHT_TOP, anchor.left_top() - vec2(6.0, 0.0))
|
||||
} else {
|
||||
(egui::Align2::LEFT_TOP, anchor.right_top() + vec2(6.0, 0.0))
|
||||
};
|
||||
let area = egui::Area::new(key.with("flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
let label_w = slot
|
||||
.iter()
|
||||
@@ -245,7 +254,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
let held_release = resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(key);
|
||||
let clicked_outside =
|
||||
ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered();
|
||||
@@ -305,8 +315,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Glyph size of a toolbar tool button. Pro fills two thirds of the button, close to Photoshop's
|
||||
@@ -315,10 +324,15 @@ fn toolbar_icon_size(box_size: f32, pro: bool) -> f32 {
|
||||
(box_size * if pro { 2.0 / 3.0 } else { 0.52 }).round()
|
||||
}
|
||||
|
||||
/// Paints a triangle on the bottom right corner of an icon button to indicate that a toolbar item has a submenu.
|
||||
/// Paints a triangle on the bottom corner of an icon button that faces the canvas (bottom right,
|
||||
/// bottom left in a right-to-left UI) to indicate that a toolbar item has a submenu.
|
||||
pub fn paint_toolbar_flyout_indicator(ui: &mut egui::Ui, tokens: &Tokens, resp: &egui::Response) {
|
||||
let r = resp.rect;
|
||||
let tri = vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)];
|
||||
let tri = if crate::direction::is_rtl() {
|
||||
vec![r.left_bottom() + vec2(2.0, -2.0), r.left_bottom() + vec2(6.0, -2.0), r.left_bottom() + vec2(2.0, -6.0)]
|
||||
} else {
|
||||
vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri, tokens.text_faint, Stroke::NONE));
|
||||
}
|
||||
|
||||
@@ -523,7 +537,7 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
// Only the free gap between the menus and the right-hand controls drags the window: a
|
||||
// Only the free gap between the menus and the far-end controls drags the window: a
|
||||
// press on a menu title must open the menu, never move the window. The gap is last
|
||||
// frame's, as the menus are laid out after this.
|
||||
let span_id = ui.id().with("titledrag-span");
|
||||
@@ -538,24 +552,40 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into());
|
||||
// The menus and the right-hand controls are laid out first; the title is centred in
|
||||
// whatever room is left between them, shortened or dropped rather than drawn over them.
|
||||
let (mut menus_right, mut controls_left) = (full.left(), full.right());
|
||||
ui.horizontal_centered(|ui| {
|
||||
// The menus and the far-end controls are laid out first; the title is centred in
|
||||
// whatever room is left between them (`free`, left to right: menus then controls, or
|
||||
// controls then menus in a right-to-left UI), shortened or dropped rather than drawn
|
||||
// over them.
|
||||
let rtl = crate::direction::is_rtl();
|
||||
let mut free = (full.left(), full.right());
|
||||
crate::direction::horizontal_centered(ui, |ui| {
|
||||
if rtl && custom {
|
||||
// The caption buttons keep the right end; the menus start beside them.
|
||||
ui.add_space(crate::titlebar::WIDTH + 4.0);
|
||||
}
|
||||
if !system {
|
||||
let side = if t.pro { 18.0 } else { 20.0 };
|
||||
let (mark, _) = ui.allocate_exact_size(vec2(side, side), Sense::hover());
|
||||
crate::brand::paint_mark(ui, mark);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
// With the macOS menu bar the menus are at the top of the screen instead.
|
||||
menus_right = if app.services.native_menu.is_some() { ui.cursor().left() } else { crate::menus::menu_bar(app, ui) };
|
||||
// The menu bar takes the whole row, so the right-hand group gets its own rect:
|
||||
// from the menus to the bar's end, or to the caption buttons.
|
||||
let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() };
|
||||
let group = egui::Rect::from_min_max(egui::pos2((menus_right + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom()));
|
||||
let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
controls_left = {
|
||||
// With the macOS menu bar the menus are at the top of the screen instead. Either way
|
||||
// this is the menus' inner edge, facing the title.
|
||||
let menus_end = if app.services.native_menu.is_some() {
|
||||
if rtl { ui.cursor().right() } else { ui.cursor().left() }
|
||||
} else {
|
||||
crate::menus::menu_bar(app, ui)
|
||||
};
|
||||
// The menu bar takes the whole row, so the far-end group gets its own rect: from
|
||||
// the menus to the bar's end, or to the caption buttons.
|
||||
let group = if rtl {
|
||||
egui::Rect::from_min_max(egui::pos2(full.left(), full.top()), egui::pos2((menus_end - TITLE_GAP).max(full.left()), full.bottom()))
|
||||
} else {
|
||||
let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() };
|
||||
egui::Rect::from_min_max(egui::pos2((menus_end + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom()))
|
||||
};
|
||||
let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(crate::direction::trailing()));
|
||||
let controls_end = {
|
||||
let ui = &mut group_ui;
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let mut ws = app.ui.workspace.clone();
|
||||
@@ -615,24 +645,27 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::links::open(app, ui.ctx(), crate::links::DISCORD);
|
||||
}
|
||||
}
|
||||
ui.min_rect().left()
|
||||
if rtl { ui.min_rect().right() } else { ui.min_rect().left() }
|
||||
};
|
||||
free = if rtl { (controls_end, menus_end) } else { (menus_end, controls_end) };
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(span_id, (menus_right, controls_left)));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(span_id, free));
|
||||
if system {
|
||||
return;
|
||||
}
|
||||
let font = theme::medium(13.0);
|
||||
let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim);
|
||||
if let Some(x) = title_x(full.center().x, menus_right, controls_left, galley.size().x) {
|
||||
if let Some(x) = title_x(full.center().x, free.0, free.1, galley.size().x) {
|
||||
ui.painter().galley(egui::pos2(x, full.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
||||
} else if controls_left - menus_right > 80.0 {
|
||||
// Too narrow for the whole name: show the start of it, elided, in the free gap.
|
||||
let avail = controls_left - menus_right - 2.0 * TITLE_GAP;
|
||||
} else if free.1 - free.0 > 80.0 {
|
||||
// Too narrow for the whole name: show the start of it, elided, in the free gap
|
||||
// next to the menus.
|
||||
let avail = free.1 - free.0 - 2.0 * TITLE_GAP;
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(title, font, t.text_dim);
|
||||
job.wrap = egui::text::TextWrapping::truncate_at_width(avail);
|
||||
let g = ui.painter().layout_job(job);
|
||||
ui.painter().galley(egui::pos2(menus_right + TITLE_GAP, full.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
let x = if rtl { free.1 - TITLE_GAP - g.size().x } else { free.0 + TITLE_GAP };
|
||||
ui.painter().galley(egui::pos2(x, full.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
}
|
||||
});
|
||||
if custom {
|
||||
@@ -674,11 +707,11 @@ const DISCORD_ROOM: f32 = 120.0;
|
||||
const WORKSPACE_MIN: f32 = 90.0;
|
||||
const ICON_SLOT: f32 = 34.0;
|
||||
|
||||
/// Left edge for a title `width` wide: centred on `center` when it fits between the menus
|
||||
/// (ending at `menus_right`) and the right-hand controls (starting at `controls_left`), else
|
||||
/// slid into that gap; None when the gap is too small for the whole title.
|
||||
fn title_x(center: f32, menus_right: f32, controls_left: f32, width: f32) -> Option<f32> {
|
||||
let (lo, hi) = (menus_right + TITLE_GAP, controls_left - TITLE_GAP - width);
|
||||
/// Left edge for a title `width` wide: centred on `center` when it fits in the free gap from
|
||||
/// `left` to `right` (the menus' and the controls' facing edges, whichever side each is on),
|
||||
/// else slid into that gap; None when the gap is too small for the whole title.
|
||||
fn title_x(center: f32, left: f32, right: f32, width: f32) -> Option<f32> {
|
||||
let (lo, hi) = (left + TITLE_GAP, right - TITLE_GAP - width);
|
||||
if !(lo.is_finite() && hi.is_finite() && width.is_finite()) || hi < lo {
|
||||
return None;
|
||||
}
|
||||
@@ -836,7 +869,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Font ink can extend beyond its advance width at fractional positions.
|
||||
// Include its gutter in the container layout, including right-aligned actions.
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { right: 2, ..Default::default() }).show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
crate::direction::horizontal_centered(ui, |ui| {
|
||||
if t.pro {
|
||||
crate::chrome_ui::home_button(app, ui);
|
||||
widgets::vline(ui, 22.0);
|
||||
@@ -1500,11 +1533,12 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)))
|
||||
.show(ui, |ui| {
|
||||
let control_h = status_bar_heights(&t).1;
|
||||
ui.horizontal_centered(|ui| {
|
||||
// Read from the right in a right-to-left UI, with the trailing group at the left.
|
||||
crate::direction::horizontal_centered(ui, |ui| {
|
||||
if t.pro {
|
||||
crate::chrome_ui::status_bar_pro(app, ui);
|
||||
// Background job progress with Cancel, at the right end (#210).
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::jobs_ui::status_progress(app, ui));
|
||||
// Background job progress with Cancel, at the far end (#210).
|
||||
ui.with_layout(crate::direction::trailing(), |ui| crate::jobs_ui::status_progress(app, ui));
|
||||
return;
|
||||
}
|
||||
if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) {
|
||||
@@ -1529,7 +1563,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn");
|
||||
ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint }));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.with_layout(crate::direction::trailing(), |ui| {
|
||||
if app.session.active().is_some()
|
||||
&& !t.pro
|
||||
&& widgets::compact_button(ui, tl!("Fit"), control_h).clicked()
|
||||
@@ -1559,11 +1593,15 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Narrow icon rail, unless an embedding app hides it: shows, expands or collapses panel groups.
|
||||
if p.rail {
|
||||
let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) };
|
||||
egui::Panel::right("rail").resizable(false).exact_size(rw).frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
crate::direction::end_panel("rail")
|
||||
.resizable(false)
|
||||
.exact_size(rw)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8)))
|
||||
.show(ui, |ui| {
|
||||
// The separator is on the canvas side.
|
||||
let r = ui.max_rect();
|
||||
ui.painter().line_segment([r.left_top() - vec2(6.0, 8.0), r.left_bottom() + vec2(-6.0, 8.0)], Stroke::new(1.0, t.separator));
|
||||
let x = if crate::direction::is_rtl() { r.right() + 6.0 } else { r.left() - 6.0 };
|
||||
ui.painter().line_segment([pos2(x, r.top() - 8.0), pos2(x, r.bottom() + 8.0)], Stroke::new(1.0, t.separator));
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
use crate::dock::Group;
|
||||
let entries: [(&str, &str, Group); 5] = [
|
||||
@@ -1585,8 +1623,7 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
if !t.pro && !customized {
|
||||
dock_panels(app, ui, &p, &t);
|
||||
@@ -1594,7 +1631,7 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::dock::persist(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// The right dock's width range (points).
|
||||
/// The dock's width range (points). It is the right dock, on the left in a right-to-left UI.
|
||||
pub(crate) const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
|
||||
|
||||
pub(crate) fn dock_width_id() -> egui::Id {
|
||||
@@ -1625,7 +1662,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
|
||||
return;
|
||||
}
|
||||
let margin = if t.pro { 2 } else { 8 };
|
||||
let mut panel = egui::Panel::right("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH);
|
||||
let mut panel = crate::direction::end_panel("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH);
|
||||
if let Some(w) = ui.ctx().data_mut(|d| d.remove_temp::<f32>(dock_width_id())) {
|
||||
panel = panel.exact_size(w);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: per-language status measured from the catalogs, against Photoshop 2026's language packs) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (right-to-left shell layout) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
|
||||
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md),
|
||||
@@ -34,7 +34,7 @@ catalog).
|
||||
| Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h |
|
||||
| Spanish | `es` | 1,608 / 1,608 (100%) | 2,413 | as above | Latin | no | partial | 6–10 h |
|
||||
| Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h |
|
||||
| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; no RTL UI layout (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h |
|
||||
| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h |
|
||||
| French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h |
|
||||
| Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h |
|
||||
| Indonesian | `id` | 1,608 / 1,608 (100%) | 2,764 | as above | Latin | no | partial | 6–10 h |
|
||||
@@ -52,7 +52,10 @@ Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (2,411
|
||||
## Cross-language gaps
|
||||
|
||||
- Engine error, status and parameter-help messages are English in every language.
|
||||
- No right-to-left UI layout (needed for Arabic, Hebrew, Persian, Urdu).
|
||||
- Right-to-left UI layout is partial (2026-10-10): the shell mirrors for Arabic, Hebrew, Persian,
|
||||
Urdu and other right-to-left codes (`direction.rs`), but panel interiors, dialogs, menu rows,
|
||||
submenus and document tabs stay left to right. UI labels also lack an Arabic fallback font, and
|
||||
egui 0.36 joins letters within a word but orders a line's words left to right (no bidi).
|
||||
- Web build: no browser-locale detection, no CJK web fonts.
|
||||
- WebAssembly plug-ins disappear from Filter › Plug-ins when the UI isn't English (#2540).
|
||||
- A user couldn't find the language option (#2532): it is in Preferences › Interface.
|
||||
@@ -62,3 +65,4 @@ Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (2,411
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First version, measured from the catalogs with the `i18n-coverage` rules |
|
||||
| 2026-10-10 | minor | Right-to-left shell layout (menu bar, title, options and status bars, toolbox and dock sides) |
|
||||
@@ -85,6 +85,29 @@ This is hot switching among the bundled catalogs. Catalog files are embedded wit
|
||||
`include_str!`; editing a TSV file requires a rebuild. External catalog watching and
|
||||
file-resource hot reload are not implemented.
|
||||
|
||||
## Right-to-left languages
|
||||
|
||||
Arabic, Hebrew, Persian, Urdu and the other right-to-left languages listed in
|
||||
`i18n::dir_for_code` mirror the window's chrome when chosen: the menu bar starts at the right
|
||||
with File, the toolbox docks on the right and the panel dock and its rail on the left, and the
|
||||
title, options and status bars read from the right (`crates/ui-egui/src/direction.rs`). The
|
||||
direction follows the language code, so a catalog needs no extra setting.
|
||||
|
||||
Not mirrored yet: panel interiors, dialogs, menu rows (shortcuts stay on the right), submenus
|
||||
(they open to the right), document tabs and the canvas. Before an Arabic catalog reads
|
||||
correctly, the UI also needs an Arabic fallback font (the bundled UI fonts have no Arabic
|
||||
glyphs) and bidirectional word order: egui 0.36 shapes each right-to-left word with HarfRust,
|
||||
so letters join, but lays a line's words out left to right, and its caret indexing is off in
|
||||
right-to-left text (emilk/egui#8576, #8577). Document text is laid out by the text engine.
|
||||
|
||||
No right-to-left catalog ships yet. To check the mirrored layout with any language, launch with
|
||||
`PHOTOCRAFT_UI_DIRECTION=rtl` (or `ltr` to force left-to-right); it is read once per process
|
||||
and has no effect on the web build:
|
||||
|
||||
```sh
|
||||
PHOTOCRAFT_UI_DIRECTION=rtl cargo run -p photocraft-ui-egui --example snapshot -- --out rtl.png
|
||||
```
|
||||
|
||||
## Fonts
|
||||
|
||||
The native lazy CJK font loader prioritises the selected Japanese, Korean or Chinese script.
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ Never-crash attribute on 28 of 28 crate roots.
|
||||
| DIST-220-1 | Help > Check for Updates plus an opt-out periodic release-feed check on all desktops | missing | [#220](https://github.com/storytold/photocraft/issues/220) | No update check in crates |
|
||||
| DIST-220-2 | Windows ARM64 (aarch64-pc-windows-msvc) in the release matrix, signed like x64 | done | [#220](https://github.com/storytold/photocraft/issues/220) | release.yml has { arch: arm64, target: aarch64-pc-windows-msvc } in the Windows matrix, and .github/workflows/windows-arm64.yml packages that MSI, installs it on a Windows 11 ARM64 runner, checks the installed binaries are ARM64 and runs the CLI natively before uninstalling. Signing takes the same path as x64: the secrets are optional and a missing one produces an unsigned artifact and a warning (see DIST-2) |
|
||||
| DIST-220-3 | In-app bug report with reviewable diagnostics that opens a prefilled issue | missing | [#220](https://github.com/storytold/photocraft/issues/220) | No bug report dialog in crates |
|
||||
| DIST-220-4 | Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew | partial | [#220](https://github.com/storytold/photocraft/issues/220) | `cargo xtask i18n-coverage` reports per-language coverage (#427); no RTL layout yet |
|
||||
| DIST-220-4 | Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew | partial | [#220](https://github.com/storytold/photocraft/issues/220) | `cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs |
|
||||
| DIST-220-5 | Linux AppImage x86_64 + aarch64 ship; Flatpak kept; Flathub later (#173) | partial | [#220](https://github.com/storytold/photocraft/issues/220) | AppImage and Flatpak ship (release.yml:162-216); Flathub not done |
|
||||
|
||||
## Open bugs
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ themselves). A test fails when a `tl!` literal, a menu string, a blend mode name
|
||||
preference label has no entry in a language marked `complete_menus`. Not translated: status-bar
|
||||
messages and errors (they stay English, also for agents), names that are user data (layers, styles,
|
||||
documents), strings assembled with `format!` that were not converted to `fmt`/`trn`. Not done yet:
|
||||
right-to-left layout,
|
||||
right-to-left layout inside panels and dialogs (the shell mirrors, `direction.rs`),
|
||||
locale-aware number and date formats, automatic language detection on the web build (native
|
||||
builds read `LANG`/`LC_*`, the macOS preferred languages and the Windows user locale).
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ id = "DIST-220-4"
|
||||
target = "Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew"
|
||||
status = "partial"
|
||||
issue = 220
|
||||
note = "`cargo xtask i18n-coverage` reports per-language coverage (#427); no RTL layout yet"
|
||||
note = "`cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-5"
|
||||
|
||||
Reference in new issue
Block a user