Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/LICENSE-translations.txt
#	crates/ui-egui/src/i18n/live_tests.rs
#	crates/ui-egui/src/i18n/system.rs
#	docs/localization-parity.md
This commit is contained in:
David Mann
2026-10-29 15:41:15 +01:00
24 changed files with 3905 additions and 196 deletions
+1
View File
@@ -23,6 +23,7 @@ next to them.
| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian 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) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese 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) |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic 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) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) 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) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian 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) |
+14 -3
View File
@@ -103,7 +103,7 @@ pub fn start() {
}
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, ui_language: "" }))
}),
)
.await;
@@ -224,7 +224,10 @@ fn load_font_manifest(served: Served, ctx: egui::Context) {
served_fonts::add_fonts(&fonts);
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
// the Arabic font before typing); the rest is fetched when text or a font menu needs it.
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str));
// Also the interface language's: `serve_fonts` asked for it once already, maybe before
// this manifest arrived, when no fallback was registered yet.
let ui_language = photocraft_ui_egui::i18n::current().code();
served_fonts::request_for_languages(browser_languages().iter().map(String::as_str).chain([ui_language]));
ctx.request_repaint();
});
}
@@ -270,6 +273,8 @@ struct WebShell {
served: Served,
database: Option<web_sys::IdbDatabase>,
presets: crate::preset_bridge::Bridge,
/// The interface language whose served script font was last asked for.
ui_language: &'static str,
}
impl WebShell {
@@ -294,6 +299,12 @@ impl WebShell {
/// Fetches the served families asked for (picked in a font menu, or needed by a layout) and
/// installs the files that arrived.
fn serve_fonts(&mut self, ctx: &egui::Context) {
// The interface language's script font draws its labels (Arabic, Japanese…).
let ui_language = photocraft_ui_egui::i18n::current().code();
if ui_language != self.ui_language {
self.ui_language = ui_language;
served_fonts::request_for_languages([ui_language]);
}
let requested = served_fonts::take_requests();
let (fetch, arrived) = {
let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner);
@@ -320,7 +331,7 @@ impl WebShell {
}
});
}
served_fonts::install(&mut self.app, arrived);
served_fonts::install(&mut self.app, ctx, arrived);
}
}
+5
View File
@@ -286,6 +286,11 @@ pub fn request_for_languages<'a>(languages: impl IntoIterator<Item = &'a str>) {
}
}
/// The served fallback family for the script `language` (a BCP 47 tag) is written in, if any.
pub fn fallback_for_language(language: &str) -> Option<String> {
fallback_for(&state().fallbacks, language_script(language)?)
}
/// Families the host can serve, installed or not (for font menus).
pub fn families() -> Vec<String> {
state().families.clone()
+3
View File
@@ -155,6 +155,9 @@ impl PhotocraftApp {
/// open in name order ([`natural_cmp`]: `File_2` before `File_10`), and their tabs keep that
/// order however long each takes to open in the background (#3248). Returns how many opened.
pub fn open_paths(&mut self, paths: &[String]) -> usize {
// The command line's files open before the first frame, which otherwise applies the
// interface language: their status lines and notices would come out in English.
crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.session.prefs().interface.language));
let mut paths: Vec<String> = paths.iter().map(|p| absolute(p)).collect();
paths.sort_by(|a, b| natural_cmp(a, b));
let batch = crate::jobs_ui::begin_batch(self);
+22
View File
@@ -818,6 +818,28 @@ fn natural_order_counts_numbers_by_value() {
assert_eq!(names, ["A.psd", "b.psd", "file_1.psd", "File_03.psd", "File_9.psd", "File_10.psd", "File_100.psd"]);
}
/// Files on the command line open before the first frame applies the interface language: their
/// status line follows the preference, not the English default.
#[test]
fn open_paths_reports_in_the_interface_language_before_the_first_frame() {
let pattern = |i: u8| {
let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
surface.fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[f32::from(i) / 3.0, 0.5, 0.2, 1.0]);
let mut p = photocraft_doc::Pattern::new("Launch", surface, 4, 4);
p.id = format!("6f1c2c1e-0000-4000-8000-00000000000{i}");
p
};
let bytes = photocraft_io::pattern_map::write_pat(&[pattern(1), pattern(2), pattern(3)]).unwrap();
let (dir, paths) = files("launch-language", &["launch.pat"], &bytes);
let (mut app, _) = app_with(Vec::new());
app.session.edit_prefs(|p| p.interface.language = "ar".into());
crate::i18n::set_current(crate::i18n::Lang::EN);
assert_eq!(app.open_paths(&paths), 1);
assert_eq!(app.ui.status, "تم استيراد 3 نقشات من launch");
crate::i18n::set_current(crate::i18n::Lang::EN);
let _ = std::fs::remove_dir_all(&dir);
}
/// #3248: several files opened at once (File › Open, the command line, Finder) open in name order.
#[test]
fn open_paths_opens_a_set_in_name_order() {
+16 -1
View File
@@ -115,6 +115,18 @@ fn plural_polish(n: u64) -> usize {
}
}
/// Arabic (CLDR): 0 → zero, 1 → one, 2 → two, n % 100 in 3–10 → few, in 11–99 → many, else other.
fn plural_arabic(n: u64) -> usize {
match (n, n % 100) {
(0, _) => 0,
(1, _) => 1,
(2, _) => 2,
(_, 3..=10) => 3,
(_, 11..=99) => 4,
_ => 5,
}
}
/// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB
/// of translations, which the web build's size gate can't afford). See [`unpack`].
macro_rules! catalog {
@@ -187,7 +199,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 23] = [
pub static LANGUAGES: [LangInfo; 24] = [
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
@@ -222,6 +234,9 @@ pub static LANGUAGES: [LangInfo; 23] = [
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Hindi; `hi`, `hi-IN` locales resolve here.
LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Arabic (Modern Standard); `ar-SA`, `ar-EG` and the other regional locales resolve here. The
// chrome mirrors right to left (`dir_for_code`), and egui lays each label out right to left.
LangInfo { code: "ar", name: "العربية", source: catalog!("ar"), plural: plural_arabic, complete_menus: true, catalog: OnceLock::new() },
// Swedish; `sv-SE` and `sv-FI` locales resolve here.
LangInfo { code: "sv", name: "Svenska", source: catalog!("sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
];
@@ -41,6 +41,10 @@ PhotoCraft's English labels; no proprietary translation resources were used.
Russian translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
Arabic translations: PhotoCraft contributors, combined from the catalogs contributed by @azizr12,
@hicbest, @hasan-sdaif, @NibrasAz7, @vfggf95565, @syrrevshin4202-prog and @test-bit-sys and
reviewed with Claude (Anthropic) as an AI contributor; original translations of PhotoCraft's
English labels, and no proprietary translation resources were used.
Swedish translations: PhotoCraft contributors, combined from the catalogs contributed by @kler and
@davvarn and reviewed with Claude (Anthropic) as an AI contributor; original translations of
PhotoCraft's English labels, and no proprietary translation resources were used.
File diff suppressed because it is too large Load Diff
+41
View File
@@ -451,3 +451,44 @@ fn czech_shortcut_conflicts_keep_warning_state_and_shortcut_codes() {
assert_eq!(fields["overrides"]["file.new"], json!("Cmd+O"));
});
}
/// The catalog stays in logical order: egui puts a right-to-left label into display order itself,
/// so the first word of an Arabic label lands to the right of its last, and a Latin brand name at
/// the end of the sentence lands at the left.
#[test]
fn arabic_labels_are_laid_out_right_to_left() {
with_language(Lang::EN, || {
let mut h = harness();
h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "ar"})).unwrap();
h.run_steps(4);
let x_of = |text: &str, c: char| {
let galley = h.ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), egui::FontId::proportional(13.0), egui::Color32::WHITE));
galley.rows.iter().flat_map(|r| r.glyphs.iter()).find(|g| g.chr == c).map(|g| g.pos.x).expect("glyph")
};
let label = tr(lang("ar"), "No properties");
assert_eq!(label, "لا توجد خصائص");
assert!(x_of(label, 'ل') > x_of(label, 'ص'), "{label}: first word at the right");
let join = tr(lang("ar"), "Join us on Discord");
assert!(x_of(join, 'D') < x_of(join, 'ن'), "{join}: Discord at the left");
});
}
/// #5159 on egui 0.37: user names are handed to egui as typed, and egui orders the words. An
/// Arabic layer name reads from the right; the title line keeps "— PhotoCraft" at the right of a
/// right-to-left document name.
#[test]
fn arabic_names_are_laid_out_right_to_left() {
with_language(Lang::EN, || {
let h = harness();
let font = egui::FontId::proportional(13.0);
let x_in = |galley: &egui::Galley, c: char| galley.rows.iter().flat_map(|r| r.glyphs.iter()).find(|g| g.chr == c).map(|g| g.pos.x).expect("glyph");
let name = "لاشي مكتوب هنا";
let galley = h.ctx.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), egui::Color32::WHITE));
assert!(x_in(&galley, 'ل') > x_in(&galley, 'ه'), "{name}: first word at the right");
let title = "لاشي مكتوب.png — PhotoCraft";
let job = crate::panels::title_bar::title_job(title, font, egui::Color32::WHITE);
let galley = h.ctx.fonts_mut(|f| f.layout_job(job));
assert!(x_in(&galley, 'ل') > x_in(&galley, 'م'), "{title}: the name's first word right of its second");
assert!(x_in(&galley, 'P') > x_in(&galley, 'ل'), "{title}: PhotoCraft stays at the right");
});
}
+1
View File
@@ -91,6 +91,7 @@ mod tests {
(vec!["zh-Hant-HK", "zh-CN"], "zh-hant"),
(vec!["zh-Hans-CN", "zh-TW"], "zh-hans"),
(vec!["en-US", "ru-RU"], "en"),
(vec!["fi-FI", "ar-SA"], "ar"),
(vec!["sw-KE", "fi-FI"], "en"),
(vec!["de-AT", "en-US"], "de"),
(vec!["pt-PT"], "pt-br"),
+32
View File
@@ -209,6 +209,38 @@ fn ukrainian_plural_rule_handles_teens_and_compound_counts() {
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]);
}
#[test]
fn arabic_resolves_locales_and_preferences() {
let ar = Lang::from_code("ar").expect("ar registered");
assert_eq!(ar.name(), "العربية");
for tag in ["ar", "AR", "ar-SA", "ar_EG", "ar_EG.UTF-8", "ar-AE", "ar-MA"] {
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
assert_eq!(Lang::from_pref(tag), ar, "{tag}");
}
assert_eq!(dir_for_code("ar"), Dir::Rtl);
assert_eq!(tr(ar, "File"), "ملف");
}
#[test]
fn arabic_plural_rule_has_six_forms() {
let ar = Lang::from_code("ar").expect("ar registered");
let counts = [0, 1, 2, 3, 10, 11, 99, 100, 101, 102, 103, 111, 199, 1000, u64::MAX];
let forms: Vec<usize> = counts.into_iter().map(ar.0.plural).collect();
assert_eq!(forms, [0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 3, 4, 4, 5, 4]);
assert_eq!(trn(ar, 2, "{n} layer", "{n} layers"), "2 طبقتان");
assert_eq!(trn(ar, 5, "{n} layer", "{n} layers"), "5 طبقات");
}
/// The preset-import result (`preset_files_ui::open`) picks its plural pair at run time, so the
/// literal-key coverage check can't see these rows.
#[test]
fn arabic_preset_import_results_are_translated() {
let ar = Lang::from_code("ar").expect("ar registered");
assert_eq!(trn(ar, 3, "Imported {n} pattern from {file}", "Imported {n} patterns from {file}"), "تم استيراد 3 نقشات من {file}");
assert_eq!(trn(ar, 2, "Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), "تم استيراد 2 تدرجين من {file}");
assert_eq!(trn(ar, 1, "Imported {n} brush from {file}", "Imported {n} brushes from {file}"), "تم استيراد 1 فرشاة من {file}");
}
#[test]
fn serbian_resolves_locales_and_preferences() {
let sr = Lang::from_code("sr").expect("sr registered");
+1 -1
View File
@@ -57,7 +57,7 @@ pub mod smart_ui;
mod status_bar;
pub mod swatches_ui;
pub(super) mod timeline_ui;
mod title_bar;
pub(crate) mod title_bar;
pub(super) mod titlebar;
mod toolbar;
pub mod type_panels_ui;
+5 -5
View File
@@ -127,14 +127,14 @@ pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color
}
painter.layout_job(job)
};
// A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end and
// then put in display order, so its start stays and "…" is at the left (`bidi_text::elide`).
// A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end, so its
// start stays and "…" is at the left (`bidi_text::elide`).
if let Some(shown) = crate::bidi_text::elide(text, |s| layout(s, false).size().x <= max_width) {
return Some(layout(&shown, false));
}
// Right-to-left names in display order: egui lays words out left to right. Left-to-right
// names are cut at their right end by egui.
Some(layout(&crate::bidi_text::visual(text), true))
// egui puts a right-to-left name into display order itself; left-to-right names are cut at
// their right end by egui.
Some(layout(text, true))
}
/// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the
+10 -5
View File
@@ -132,18 +132,15 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if system {
return;
}
// A right-to-left document name in display order (the OS title keeps the logical text),
// in a left-to-right line: "— PhotoCraft" stays after the name, at the right.
let title = crate::bidi_text::visual_ltr(&title).into_owned();
let font = theme::medium(13.0);
let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim);
let galley = ui.painter().layout_job(title_job(&title, font.clone(), t.text_dim));
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 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);
let mut job = title_job(&title, font, t.text_dim);
job.wrap = egui::text::TextWrapping::truncate_at_width(avail);
let g = ui.painter().layout_job(job);
let x = if rtl { free.1 - TITLE_GAP - g.size().x } else { free.0 + TITLE_GAP };
@@ -185,6 +182,14 @@ pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
}
/// The in-app title line. It reads left to right as a whole, so a right-to-left document name keeps
/// "— PhotoCraft" after it, at the right; egui orders the name's own words.
pub(crate) fn title_job(title: &str, font: egui::FontId, color: egui::Color32) -> egui::text::LayoutJob {
let mut job = egui::text::LayoutJob::simple_singleline(title.to_owned(), font, color);
job.direction = egui::epaint::text::TextDirection::LeftToRight;
job
}
/// Minimum space kept between the window title and the menus or controls beside it.
const TITLE_GAP: f32 = 16.0;
+14 -157
View File
@@ -1,31 +1,11 @@
//! Right-to-left text in labels that egui draws (#5159).
//!
//! egui shapes text in runs of one font face and places the runs left to right. An Arabic or
//! Hebrew word is one run, which the shaper draws right to left, but the space between two words
//! comes from another font, so a phrase came out with every word correct and the word order
//! reversed ("THIS IS A TEXT" read as "TEXT A IS THIS"). [`visual`] puts a label into display
//! order with the Unicode Bidirectional Algorithm before egui lays it out: words of a
//! right-to-left script stay in logical order (the shaper reverses them itself), and everything
//! else in a right-to-left run is reversed around them, with brackets mirrored.
//!
//! For display only: never store the result or put it in an editable field.
//! egui lays text out with the Unicode Bidirectional Algorithm: each paragraph takes the direction
//! of its first strong letter, and a right-to-left run is placed right to left. Labels, names and
//! translations are therefore handed to egui in logical order, as typed. What egui can't know is
//! where a right-to-left name may be cut: [`elide`] keeps the *start* of the name.
use std::borrow::Cow;
use unicode_bidi::{BidiClass, BidiInfo, Level, bidi_class};
/// `text` in the order egui should lay it out from left to right. Text without right-to-left
/// characters comes back unchanged, without allocating.
pub fn visual(text: &str) -> Cow<'_, str> {
visual_at(text, None)
}
/// [`visual`] for a line that reads left to right as a whole even when it starts with a
/// right-to-left name, such as the title bar's "*صورة.psd — PhotoCraft": with the paragraph
/// direction taken from the name, the "— PhotoCraft" tail moved to the left.
pub fn visual_ltr(text: &str) -> Cow<'_, str> {
visual_at(text, Some(Level::ltr()))
}
use unicode_bidi::{BidiClass, bidi_class};
/// Whether `text` reads right to left as a paragraph: its first strong letter is Hebrew, Arabic…
pub fn is_rtl_paragraph(text: &str) -> bool {
@@ -38,20 +18,19 @@ pub fn is_rtl_paragraph(text: &str) -> bool {
.unwrap_or(false)
}
/// `text` in display order, cut with "…" where it doesn't fit (`fits` measures a display-order
/// string). A right-to-left name is cut in logical order before it is reordered, so its *start*
/// stays and the "…" lands at the left, the end of a right-to-left line; cutting after the
/// reordering lost the start of the name instead. `None`: `text` fits as it is (or a
/// left-to-right one, which the caller elides at its right end as usual).
/// A right-to-left `text` cut at its logical end with "…" where it doesn't fit (`fits` measures a
/// candidate). The start of the name stays, and egui puts the "…" at the left, the end of a
/// right-to-left line. `None`: `text` fits as it is (or a left-to-right one, which the caller
/// elides at its right end as usual).
pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option<String> {
if !is_rtl_paragraph(text) || fits(&visual(text)) {
if !is_rtl_paragraph(text) || fits(text) {
return None;
}
// The longest logical prefix (whole characters) that fits with "…" after it.
let ends: Vec<usize> = text.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(text.len())).collect();
let cut = |n: usize| -> String {
let end = if n == 0 { 0 } else { ends.get(n - 1).copied().unwrap_or(text.len()) };
visual(&format!("{}…", text.get(..end).unwrap_or_default().trim_end())).into_owned()
format!("{}…", text.get(..end).unwrap_or_default().trim_end())
};
let (mut lo, mut hi) = (0usize, ends.len());
while lo < hi {
@@ -61,143 +40,21 @@ pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option<String> {
Some(cut(lo))
}
fn visual_at(text: &str, level: Option<Level>) -> Cow<'_, str> {
if !text.chars().any(is_rtl) {
return Cow::Borrowed(text);
}
let info = BidiInfo::new(text, level);
let mut out = String::with_capacity(text.len());
for para in &info.paragraphs {
let (levels, runs) = info.visual_runs(para, para.range.clone());
for run in runs {
let Some(slice) = text.get(run.clone()) else { continue };
if levels.get(run.start).is_some_and(|level| level.is_rtl()) {
push_reversed(slice, &mut out);
} else {
out.push_str(slice);
}
}
}
Cow::Owned(out)
}
/// A strong right-to-left letter, or any character of a right-to-left script block (Arabic
/// punctuation such as "،" belongs with the word it follows: the same font shapes both).
fn is_rtl(c: char) -> bool {
matches!(bidi_class(c), BidiClass::R | BidiClass::AL) || matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}')
}
/// Characters that stay inside a right-to-left word: its letters, combining marks, and joiners.
fn keeps_with_word(c: char) -> bool {
is_rtl(c) || matches!(bidi_class(c), BidiClass::NSM | BidiClass::BN)
}
/// Append a right-to-left run in left-to-right display order: right-to-left words stay whole and
/// in logical order, every other character is reversed around them, and brackets are mirrored.
fn push_reversed(run: &str, out: &mut String) {
let mut units: Vec<&str> = Vec::new();
let mut word: Option<usize> = None;
for (i, c) in run.char_indices() {
if keeps_with_word(c) {
word.get_or_insert(i);
continue;
}
if let Some(start) = word.take()
&& let Some(w) = run.get(start..i)
{
units.push(w);
}
if let Some(single) = run.get(i..i + c.len_utf8()) {
units.push(single);
}
}
if let Some(start) = word
&& let Some(w) = run.get(start..)
{
units.push(w);
}
for unit in units.into_iter().rev() {
let mut chars = unit.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if !keeps_with_word(c) => out.push(mirror(c)),
_ => out.push_str(unit),
}
}
}
/// The mirrored form of a bracket shown in right-to-left text.
fn mirror(c: char) -> char {
match c {
'(' => ')',
')' => '(',
'[' => ']',
']' => '[',
'{' => '}',
'}' => '{',
'<' => '>',
'>' => '<',
'«' => '»',
'»' => '«',
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn left_to_right_text_is_untouched_and_not_copied() {
for text in ["PhotoCraft", "Layer 1", "", "Background copy (2)", "日本語のレイヤー"] {
assert!(matches!(visual(text), Cow::Borrowed(t) if t == text), "{text}");
}
}
/// #5159: the reported layer name. Each word stays as typed; the word order flips so that
/// egui, placing words left to right, shows the first word at the right.
#[test]
fn arabic_words_are_placed_right_to_left() {
assert_eq!(visual("لاشي مكتوب هنا"), "هنا مكتوب لاشي");
assert_eq!(visual("שלום עולם"), "עולם שלום");
}
#[test]
fn numbers_and_latin_keep_their_own_order() {
// A number inside right-to-left text reads left to right, at the start of the line.
assert_eq!(visual("طبقة 2"), "2 طبقة");
assert_eq!(visual("طبقة 12"), "12 طبقة");
// A left-to-right name with an Arabic word at the end is left as it is.
assert_eq!(visual("Layer 1 نص"), "Layer 1 نص");
// Latin inside an Arabic name stays readable.
assert_eq!(visual("نص PhotoCraft هنا"), "هنا PhotoCraft نص");
}
/// #5385 review: the title bar's line is left to right as a whole, so a right-to-left
/// document name keeps "— PhotoCraft" after it, at the right.
#[test]
fn the_title_line_stays_left_to_right_around_a_right_to_left_name() {
assert_eq!(visual_ltr("*صورة.psd — PhotoCraft"), "*صورة.psd — PhotoCraft");
assert_eq!(visual_ltr("لاشي مكتوب.png — PhotoCraft"), "مكتوب لاشي.png — PhotoCraft");
assert!(matches!(visual_ltr("Untitled-1 — PhotoCraft"), Cow::Borrowed(_)));
}
/// #5385 review: a right-to-left name that doesn't fit keeps its first words and ends with
/// "…" at the left; a left-to-right or fitting name is left to the caller.
/// "…"; a left-to-right or fitting name is left to the caller.
#[test]
fn right_to_left_names_are_cut_at_their_logical_end() {
let fits = |n: usize| move |s: &str| s.chars().count() <= n;
// Cut anywhere, as egui elides: the first word and the start of the second.
assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("…مك لاشي"));
assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("…עולם לשלום"));
assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("لاشي مك…"));
assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("לשלום עולם…"));
assert_eq!(elide("لاشي مكتوب هنا", fits(40)), None, "fits");
assert_eq!(elide("Layer copy 12", fits(4)), None, "left to right: elided by egui at its end");
assert_eq!(elide("نص", fits(0)).as_deref(), Some("…"), "nothing fits: just the ellipsis");
assert!(is_rtl_paragraph("12 نص") && !is_rtl_paragraph("Layer نص") && !is_rtl_paragraph("123"));
}
#[test]
fn brackets_mirror_and_arabic_punctuation_stays_with_its_word() {
assert_eq!(visual("نص (1)"), "(1) نص");
assert_eq!(visual("مرحبا، عالم"), "عالم مرحبا،");
}
}
+1 -1
View File
@@ -1014,4 +1014,4 @@ pub(crate) fn set_craft_font_scale(ctx: &egui::Context, scale: f32) {
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests;
pub(crate) mod tests;
+1 -1
View File
@@ -433,7 +433,7 @@ fn two_face_ttc(first: &[u8], second: &[u8]) -> Vec<u8> {
/// A minimal TrueType font mapping the `ranges` (inclusive) to box glyphs, so tests have a
/// font for a script without committing font files.
fn synthetic_font(ranges: &[(u32, u32)]) -> Vec<u8> {
pub(crate) fn synthetic_font(ranges: &[(u32, u32)]) -> Vec<u8> {
let be16 = |v: &mut Vec<u8>, x: i32| v.extend((x as u16).to_be_bytes());
let be32 = |v: &mut Vec<u8>, x: u32| v.extend(x.to_be_bytes());
let chars: u32 = ranges.iter().map(|(a, b)| b - a + 1).sum();
+5 -4
View File
@@ -1,6 +1,6 @@
//! 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`.
//! running shell. The shell tests force the direction with `direction_scope`, so they don't
//! depend on a right-to-left language being selected.
use super::*;
use crate::PhotocraftApp;
@@ -17,9 +17,10 @@ fn right_to_left_scripts_by_language_code() {
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.
// Arabic is the one right-to-left catalog; every other registered language stays left to right.
for lang in Lang::all() {
assert_eq!(lang.dir(), Dir::Ltr, "{}", lang.code());
let want = if lang.code() == "ar" { Dir::Rtl } else { Dir::Ltr };
assert_eq!(lang.dir(), want, "{}", lang.code());
}
}
+168 -3
View File
@@ -1,8 +1,13 @@
//! Fonts a host serves next to the app, for the platform shell that fetches them (the web build;
//! see `photocraft_text::served`): reading the manifest, the families to fetch, and installing
//! what arrived, which registers the files and re-renders the type layers drawn without them.
//! A served script fallback (Arabic, Japanese…) also joins the UI's font stack: the web build has
//! no system fonts, so it is what draws an Arabic or Japanese interface.
use std::sync::{Mutex, PoisonError};
use std::sync::{Arc, Mutex, PoisonError};
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
use egui::{FontData, FontFamily};
use photocraft_doc::{DocId, LayerContent, LayerId, TextLayer};
pub use photocraft_text::served::{ServedFont, add_families, add_fonts, is_relative_path, parse_manifest, request_for_languages, take_requests};
@@ -12,12 +17,73 @@ use crate::PhotocraftApp;
/// Layers whose document was busy with a background job when their font arrived.
static PENDING: Mutex<Vec<(DocId, LayerId)>> = Mutex::new(Vec::new());
/// Served script fallbacks in the UI's font stack, by egui font name, unscaled: kept so the stack
/// `theme::install_fonts` rebuilds on a language change includes them again.
static UI_FACES: Mutex<Vec<(String, Arc<FontData>)>> = Mutex::new(Vec::new());
/// The served faces for the end of the UI's font stack (see [`UI_FACES`]), the interface
/// language's own first: a Japanese and a Simplified Chinese face both cover Han, and egui draws a
/// character with the first face in the stack that has it, so a Chinese interface must not get
/// the Japanese face only because it arrived first.
pub fn ui_faces() -> Vec<(String, Arc<FontData>)> {
let mut faces = UI_FACES.lock().unwrap_or_else(PoisonError::into_inner).clone();
if let Some(own) = ui_language_face() {
faces.sort_by_key(|(n, _)| *n != own);
}
faces
}
/// The egui font name of the interface language's served script fallback, if the host serves one.
fn ui_language_face() -> Option<String> {
photocraft_text::served::fallback_for_language(crate::i18n::current().code()).map(|f| face_name(&f))
}
fn face_name(family: &str) -> String {
format!("served-{family}")
}
/// Puts a delivered script fallback at the end of every UI font family, once. A file egui cannot
/// read is left out: egui panics on a configured font it cannot parse.
fn add_ui_face(ctx: &egui::Context, family: &str, bytes: &[u8]) {
if !photocraft_text::cjk::font_face_is_readable(bytes, 0) {
log::warn!("the served font {family:?} is not a readable font file; the interface does not use it");
return;
}
let name = face_name(family);
let mut data = {
let mut faces = UI_FACES.lock().unwrap_or_else(PoisonError::into_inner);
if faces.iter().any(|(n, _)| *n == name) {
return;
}
let data = FontData::from_owned(bytes.to_vec());
faces.push((name.clone(), Arc::new(data.clone())));
data
};
// The interface language's face arriving after another served face must go ahead of it, which
// appending cannot do: rebuild the stack, as a language change does.
let others = ctx.fonts(|f| f.definitions().font_data.keys().any(|n| n.starts_with("served-")));
if others && ui_language_face().as_ref() == Some(&name) {
crate::theme::install_fonts(ctx);
return;
}
crate::theme::size_ui_font(ctx, &name, &mut data);
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for f in [FontFamily::Proportional, FontFamily::Monospace] {
if !families.contains(&f) {
families.push(f);
}
}
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
ctx.add_font(FontInsert { name, data, families });
}
/// Registers delivered font files (`(family, bytes)`, the family as the manifest lists it) and
/// re-renders the type layers that use those families, or whose text falls back to them (a script
/// fallback, e.g. Arabic set in Inter), and show our own layout. A PSD layer still
/// showing Photoshop's pixels (not edited yet) keeps them, as it does when its font is installed
/// from the start. Call it every frame: it also retries the layers of a document that was busy.
pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec<u8>)>) {
/// from the start. A script fallback also joins the UI's font stack. Call it every frame: it also
/// retries the layers of a document that was busy.
pub fn install(app: &mut PhotocraftApp, ctx: &egui::Context, delivered: Vec<(String, Vec<u8>)>) {
let mut layers = std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner));
if !delivered.is_empty() {
let families: Vec<String> = delivered.iter().map(|(f, _)| f.clone()).collect();
@@ -34,6 +100,12 @@ pub fn install(app: &mut PhotocraftApp, delivered: Vec<(String, Vec<u8>)>) {
}
}
}
let fallbacks = photocraft_text::served::script_fallback_families();
for (family, bytes) in &delivered {
if fallbacks.contains(family) {
add_ui_face(ctx, family, bytes);
}
}
let mut eng = photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner);
for (family, bytes) in delivered {
let added = eng.fonts.register_font_data(bytes);
@@ -89,4 +161,97 @@ mod tests {
assert!(!redraws_for(&layer("hello"), &[thai.into()]));
assert!(redraws_for(&layer("hello"), &["Inter".into()]), "and a layer still redraws for its own family");
}
/// #10006: the web build has no system fonts, so a served Arabic fallback is what draws an
/// Arabic interface, also after a language change rebuilds the UI's font stack.
#[test]
fn a_served_script_fallback_draws_the_ui_and_survives_a_font_rebuild() {
// The web build's sources: no system fonts, no embedded craft fonts.
let web = || crate::cjk_fonts::Sources {
locale: || None,
files: |_| Vec::new(),
last_resort: Vec::new,
pending: || false,
scripts: |_| Vec::new(),
covers: |_, _| None,
embedded: crate::cjk_fonts::no_embedded,
eager_craft_fonts: false,
};
let ctx = egui::Context::default();
crate::theme::install_fonts_with(&ctx, web());
ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);
// Font changes take effect on the next pass: one empty pass, then draw Arabic.
let draw = |ctx: &egui::Context, text: &str| {
ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label(text);
})
.textures_delta
.clear();
};
draw(&ctx, "File");
add_ui_face(&ctx, "Served Test Arabic", &crate::cjk_fonts::tests::synthetic_font(&[(0x0600, 0x06FF)]));
draw(&ctx, "File");
draw(&ctx, "ملف");
// A language change rebuilds the stack; the served face comes back with it.
crate::theme::install_fonts_with(&ctx, web());
draw(&ctx, "File");
draw(&ctx, "ملف");
}
/// #10006: a host serving a broken file as a script fallback must not stop the interface;
/// egui panics on a configured font it cannot parse.
#[test]
fn an_unreadable_served_font_stays_out_of_the_ui() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let draw = |ctx: &egui::Context| {
ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label("File ملف");
})
.textures_delta
.clear();
};
draw(&ctx);
add_ui_face(&ctx, "Served Test Broken", b"<html>not found</html>");
draw(&ctx);
crate::theme::install_fonts(&ctx);
draw(&ctx);
assert!(!ui_faces().iter().any(|(n, _)| n == "served-Served Test Broken"));
}
/// #10006: a Japanese and a Simplified Chinese face both cover Han. A Chinese interface draws
/// it with the Chinese one, even when the Japanese face arrived first.
#[test]
fn the_interface_languages_served_face_comes_first_in_the_ui_stack() {
let (jpan, hans) = ("Served Test Jpan", "Served Test Hans");
photocraft_text::served::add_fonts(&parse_manifest(&format!("{jpan} | Regular | fonts/j/J.ttf | Jpan\n{hans} | Regular | fonts/h/H.ttf | Hans\n")).0);
crate::i18n::set_current(crate::i18n::Lang::from_code("zh-hans").expect("Simplified Chinese"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let draw = |ctx: &egui::Context| {
ctx.run_ui(egui::RawInput::default(), |ui| {
ui.label("文件");
})
.textures_delta
.clear();
};
let order = |ctx: &egui::Context| {
let stack = ctx.fonts(|f| f.definitions().families[&FontFamily::Proportional].clone());
let at = |family: &str| stack.iter().position(|n| *n == format!("served-{family}"));
(at(hans), at(jpan))
};
draw(&ctx);
let han = crate::cjk_fonts::tests::synthetic_font(&[(0x4E00, 0x9FFF)]);
add_ui_face(&ctx, jpan, &han);
draw(&ctx);
add_ui_face(&ctx, hans, &han);
draw(&ctx);
let (h, j) = order(&ctx);
assert!(h.is_some() && h < j, "the Chinese face goes ahead of the Japanese one: {h:?} {j:?}");
crate::theme::install_fonts(&ctx);
draw(&ctx);
let (h, j) = order(&ctx);
assert!(h.is_some() && h < j, "also after a rebuild: {h:?} {j:?}");
crate::i18n::set_current(crate::i18n::Lang::EN);
}
}
+7
View File
@@ -878,6 +878,13 @@ pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) {
// family stack ahead of the Japanese or Korean face, and a locale without a Traditional Chinese
// craft face leaves Han to the system font. (A single linear per-family chain still decides a
// glyph once a broad face is discovered; see `cjk_fonts`.)
// Served script fallbacks the web build has received (Arabic, Japanese…), after everything else.
for (name, data) in crate::served_fonts::ui_faces() {
fonts.font_data.insert(name.clone(), data);
for family in [FontFamily::Proportional, FontFamily::Monospace] {
fonts.families.entry(family).or_default().push(name.clone());
}
}
// Named weights fall back to the default stack for missing glyphs.
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
for (fam, primary) in [("medium", medium), ("semibold", semibold)] {
+8 -7
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-28 · **Change:** minor (Swedish UI catalog; previously minor: Arabic paragraph direction buttons, #6793) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-29 · **Change:** minor (Swedish UI catalog; previously minor: Arabic UI catalog) · **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), [Spanish](localization-es.md),
@@ -24,7 +24,7 @@ Vietnamese UI; Arabic and Hebrew users get English "Middle East" builds with the
composer, and its text engine handles RTL, Devanagari and Indic scripts.
**Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the
engine messages), 3 absent (Hindi, Arabic, Vietnamese), plus 8 other languages. Weighted by
engine messages), 2 absent (Hindi, Vietnamese), plus 8 other languages. Weighted by
speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorded only for Italian,
2026-10-12).
@@ -36,7 +36,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
| 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,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h |
| Hindi | `hi` | partial | 448 | Top menus, toolbox tools, camera raw, dialogs, plurals | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | partial | 15–20 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 buttons but no visual RTL caret (TYPE-218-2 partial), layer names as tofu (#1408, #1693) | no | none | 40–60 h |
| Arabic | `ar` | 2,175 / 2,175 (100%, 2026-10-28) | 3,410 | as above; combined from the community catalogs with one term per concept | Arabic shaping and bidi in the UI (egui 0.37) and the text engine, Arabic fallback font; RTL shell layout but not inside panels (DIST-220-4), paragraph direction buttons but no visual RTL caret (TYPE-218-2 partial) | no | partial | 15–25 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 |
@@ -72,9 +72,9 @@ engine localization or native review is complete.
- Engine error, status and parameter-help messages are English in every language.
- 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.
submenus and document tabs stay left to right. Word order within a label is right: egui 0.37
lays text out with the Unicode Bidirectional Algorithm.
- Web build: no browser-locale detection. It has no system fonts: Arabic, CJK and other non-Latin interface labels are drawn with the host's served script fonts (`packaging/web/README.md` › Fonts), and show as boxes on a host that serves none.
- 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.
@@ -94,4 +94,5 @@ engine localization or native review is complete.
| 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test |
| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) |
| 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) |
| 2026-10-28 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 |
| 2026-10-28 | minor | Arabic (`ar`) UI catalog: every measured key, six plural forms; layer names and the title bar no longer reversed on egui 0.37 |
| 2026-10-29 | minor | Swedish (`sv`) UI catalog: every measured key plus the dynamic labels, combined from #5985 and #9536 |
+8 -6
View File
@@ -105,13 +105,15 @@ title, options and status bars read from the right (`crates/ui-egui/src/directio
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.
(they open to the right), document tabs and the canvas. egui 0.37 lays text out with the Unicode
Bidirectional Algorithm (each paragraph takes the direction of its first strong letter), so
catalogs, layer names and document names are handed to it in logical order, as typed; never
pre-reorder a label. The title line forces a left-to-right paragraph so "— PhotoCraft" stays at
the right of a right-to-left document name, and `bidi_text::elide` cuts a right-to-left name at
its logical end. 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
Arabic (`ar`) is the first right-to-left catalog: Modern Standard Arabic, Western digits, the six
CLDR plural forms (zero|one|two|few|many|other). 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:
+1 -1
View File
@@ -293,7 +293,7 @@ Never-crash attribute on 30 of 30 crate roots.
| DIST-220-1 | Help > Check for Updates plus an opt-out periodic release-feed check on all desktops | partial | [#220](https://github.com/storytold/photocraft/issues/220) | 2026-10-22 (#5485): Help > Check for Updates and an opt-out check once per launch (updates_cmds + the desktop HTTPS transport; none inside Flatpak, where flatpak update owns updates). Missing: a periodic recheck while the app stays open |
| 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); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs |
| 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; Arabic (`ar`) is the first RTL catalog |
| 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
View File
@@ -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); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs"
note = "`cargo xtask i18n-coverage` reports per-language coverage (#427); RTL languages mirror the shell (`direction.rs`), not yet panel interiors or dialogs; Arabic (`ar`) is the first RTL catalog"
[[item]]
id = "DIST-220-5"