UI: set the interface language in Preferences

This commit is contained in:
aiongg
2026-10-11 13:50:21 +00:00
parent 16a7365701
commit f2f07a9b86
6 changed files with 90 additions and 24 deletions
+72
View File
@@ -1083,6 +1083,21 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he
});
}
_ => {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Interface Language")).font(semibold(12.0)));
// Each language by its own name, so the list reads the same in every interface language.
let current = crate::i18n::LANGUAGES.iter().find(|(code, _)| *code == app.ui.language).map_or("English", |(_, name)| *name);
egui::ComboBox::from_id_salt("pref_language").width(180.0).height(400.0).selected_text(crate::rtl::widget(ui, current)).show_ui(
ui,
|ui| {
for (code, name) in crate::i18n::LANGUAGES {
if ui.selectable_label(*code == app.ui.language, crate::rtl::widget(ui, *name)).clicked() {
// Applied by `show` right after this frame, which has `&mut DesignApp`.
d.fields.insert("applyLanguage".into(), json!(code));
}
}
},
);
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "When Scaling")).font(semibold(12.0)));
check(ui, d, "scaleStrokes", crate::i18n::tr(&app.ui.language, "Include Stroke Weight"));
ui.add_space(6.0);
@@ -1728,6 +1743,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
});
});
});
// Preferences › General › Interface Language takes effect at once, like any other app setting.
if let Some(lang) = d.fields.remove("applyLanguage")
&& let Err(e) = app.run("app.language", json!({"lang": lang}))
{
d.fields.insert("status".into(), json!(e));
}
app.ui.dialog = Some(d);
match result {
Some(true) => {
@@ -3832,6 +3853,54 @@ mod tests {
}
}
#[test]
fn preferences_first_setting_switches_interface_language() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("app.preferences", json!({})).unwrap();
let ctx = dialog_context("");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0));
for _ in 0..3 {
dialog_frame(&mut app, &ctx, screen, vec![]);
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let heading = visible_label(&labels, screen, "Interface Language");
let combo = visible_label(&labels, screen, "English");
let scaling = visible_label(&labels, screen, "When Scaling");
assert!(heading.bottom() <= combo.top() && combo.bottom() <= scaling.top(), "the language comes first: {heading:?} {combo:?} {scaling:?}");
let pane = labels.iter().filter(|(_, rect, _)| rect.left() >= heading.left() - 1.0 && rect.top() < heading.top());
assert!(pane.clone().all(|(text, _, _)| text == "Preferences"), "nothing above it: {:?}", pane.collect::<Vec<_>>());
// Choosing a language applies it at once; the dialog stays open.
click_dialog(&mut app, &ctx, screen, combo.center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for (_, name) in crate::i18n::LANGUAGES {
assert!(labels.iter().any(|(text, _, _)| text == name), "{name} is listed by its own name");
}
let deutsch = visible_label(&labels, screen, "Deutsch").center();
click_dialog(&mut app, &ctx, screen, deutsch);
assert_eq!(app.ui.language, "de");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let heading = visible_label(&labels, screen, "Sprache der Oberfläche");
let combo = visible_label(&labels, screen, "Deutsch");
// The names stay endonyms in the German interface.
click_dialog(&mut app, &ctx, screen, combo.center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for name in ["English", "Français", "日本語", "Українська"] {
assert!(labels.iter().any(|(text, _, _)| text == name), "{name}");
}
click_dialog(&mut app, &ctx, screen, heading.center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
assert!(!labels.iter().any(|(text, _, _)| text == "Français"), "the list closes");
// The control channel's command and the combo show the same setting.
app.run("app.language", json!({"lang": "ja"})).unwrap();
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
assert!(labels.iter().any(|(text, _, _)| text == "日本語"));
assert!(labels.iter().any(|(text, _, _)| text == crate::i18n::tr("ja", "Interface Language")));
}
#[test]
fn preferences_sidebar_scroll_keeps_selected_options_visible() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -3870,6 +3939,9 @@ mod tests {
}
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let body = visible_label(&labels, screen, "When Scaling").center();
// The last General option sits below this short viewport.
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0));
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
let option = "Absolute Numbering (instead of Section Numbering)";
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == option).unwrap();
assert!(rect.right() > clip.right(), "rightmost option starts horizontally clipped: {rect:?}, {clip:?}");
+3 -2
View File
@@ -1,5 +1,6 @@
//! Interface language (Edit › Interface Language): translations of menu titles, the most used
//! menu items and panel names. Untranslated strings stay English. The translations are our own.
//! Interface language (Preferences › General › Interface Language): translations of menu titles,
//! the most used menu items and panel names. Untranslated strings stay English. The translations
//! are our own.
use std::{collections::HashMap, sync::OnceLock};
-1
View File
@@ -1515,7 +1515,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Step and Repeat", "間隔を指定して複製"),
("Table of Contents", "目次"),
("Type on a Path Options", "パス上のテキストのオプション"),
("Edit/Interface Language", "編集/インターフェイスの言語"),
("Edit/Spelling", "編集/スペルチェック"),
("Edit/Transparency Blend Space", "編集/透明合成のカラースペース"),
("Edit/Transparency Flattener Presets", "編集/透明の統合プリセット"),
+1 -1
View File
@@ -195,7 +195,7 @@ pub struct UiState {
pub dynamic_spelling: bool,
/// Preferences › Story Editor Display: text size (points).
pub story_editor_size: f32,
/// Edit › Interface Language: supported codes are listed in `i18n::LANGUAGES`.
/// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`.
pub language: String,
/// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export.
pub flattener: String,
+13 -19
View File
@@ -38,7 +38,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"app.language",
"Interface Language",
None,
"{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch)",
"{lang?: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch); without lang, opens Preferences, where it is the first setting",
),
(
"app.flattener",
@@ -254,18 +254,6 @@ pub const MENUS: &[(&str, &[&str])] = &[
"cmd:edit.transparencyBlendSpace|Document CMYK|{\"space\": \"cmyk\"}",
"<",
"ui:app.colorSettings",
">Interface Language",
"ui:app.language|English|{\"lang\": \"\"}",
"ui:app.language|Deutsch|{\"lang\": \"de\"}",
"ui:app.language|Français|{\"lang\": \"fr\"}",
"ui:app.language|Español|{\"lang\": \"es\"}",
"ui:app.language|日本語|{\"lang\": \"ja\"}",
"ui:app.language|简体中文|{\"lang\": \"zh\"}",
"ui:app.language|العربية|{\"lang\": \"ar\"}",
"ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}",
"ui:app.language|Italiano|{\"lang\": \"it\"}",
"ui:app.language|Українська|{\"lang\": \"uk\"}",
"<",
">Transparency Flattener Presets",
"ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}",
"ui:app.flattener|High Resolution|{\"preset\": \"high\"}",
@@ -859,7 +847,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
}
}
"app.language" => {
let v = p.get("lang").and_then(Value::as_str).unwrap_or("");
let Some(v) = p.get("lang").and_then(Value::as_str) else { return Some(app.run("app.preferences", json!({}))) };
if !crate::i18n::LANGUAGES.iter().any(|(c, _)| *c == v) {
return Some(Err(format!("unknown language `{v}`")));
}
@@ -2643,6 +2631,16 @@ mod tests {
assert_eq!(checked(&app, "app.language", &json!({"lang": "de"})), Some(true));
assert_eq!(crate::i18n::tr(&app.ui.language, "Window"), "Fenster");
assert!(run_ui(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err());
// The language is set in Preferences, not from a menu; without `lang` the command opens it.
let mut items = Vec::new();
for (_, entries) in menu_tree() {
walk(&entries, &mut items);
}
assert!(!items.iter().any(|(_, id, _)| id == "app.language"), "no menu item sets the language");
run_ui(&mut app, "app.language", &json!({})).unwrap().unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
assert_eq!(app.ui.language, "de");
app.ui.dialog = None;
run_ui(&mut app, "app.language", &json!({"lang": "zh"})).unwrap().unwrap();
assert_eq!(app.ui.language, "zh");
@@ -2702,11 +2700,7 @@ mod tests {
assert_eq!(restored.language, "uk");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(menu_tree().iter().flat_map(|(_, entries)| entries.iter()).any(|entry| {
matches!(entry, Item::Sub(label, children) if label == "Interface Language" && children.iter().any(|child| {
matches!(child, Item::Cmd { label, id, params, .. } if label == "Українська" && id == "app.language" && params["lang"] == "uk")
}))
}));
assert!(crate::i18n::LANGUAGES.contains(&("uk", "Українська")), "Preferences lists Ukrainian by its own name");
}
#[test]
+1 -1
View File
@@ -362,7 +362,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.