Files
cadcraft/crates/ui-egui/src/options/mod.rs
T
1e075f76c5 Arabic (العربية) UI translation: language switching + font fallback (#798)
* Show Thai file names and text in the interface with an installed Thai font

A drawing whose file name is Thai showed boxes in its file tab and in the
window title: the interface loaded one Latin system font (plus a CJK font for
the Chinese interface), and neither has Thai.

install_fonts now also adds an installed Thai font (Thonburi, Leelawadee UI,
Noto Sans Thai, Loma...) after the Latin one, in any interface language, since
file names and drawing text are Thai whatever the interface is in. No font is
bundled; without one installed nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add Korean interface localization and Hangul font support

* Add Arabic (العربية) interface translation

A full ar.tsv catalog for the CAD interface — every menu label, tool,
palette, dialog and status message in the repo's three-column TSV
format, written from the plain meaning of the English with the
Arabic AutoCAD vocabulary its users know (أمر، إزاحة، تدوير، قص،
استدارة، طبقة…), with the six CLDR plural forms for the counts.
Registers ar in LANGUAGES with plural_arabic and the font chain
(Noto Sans Arabic first on Linux, Segoe UI/Tahoma on Windows, Geeza
Pro on macOS) so the UI families paint Arabic.

The shell itself still lays out left-to-right: egui 0.36 has no
global direction switch and no bidi, so mirroring is left for a
later pass; this PR is the catalog, the registry entry and the
fonts.

* i18n: start the Swedish (sv) interface catalog

Header only, not registered yet: the catalog is filled and the language registered in this
branch, translated from the meaning of CADCraft's English strings and reviewed by a native
Swedish speaker.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: add Swedish (sv) interface translation

A complete 1,235-entry Swedish catalog: every key of the Spanish catalog, Run Script... and the
four menu headings (Visa, Rita, Måttsättning, Ändra). Written from the meaning of CADCraft's
English strings; no other product's localisation and no other catalog was used. AI-assisted; the
terminology and the running app were reviewed by a native speaker. Command-line keywords, system
variables and osnap tokens stay English.

- Preference::Swedish (sv); sv-SE, sv-FI and sv_SE.UTF-8 select it; one|other plurals (the
  English rule).
- sv in REPORTED; tests for locales, saved preference and plurals, menu headings, a live switch,
  dialogs and the Swedish letters in the bundled fonts. No gaps are reported for Swedish.
- ATTRIBUTION, README, docs/localization.md, docs/localization-parity.md,
  docs/control-protocol.md, the options doc comment and a contributors/people.toml entry for
  @holmbom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Chanthawat Kiriyadee <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: huk082 <[email protected]>
Co-authored-by: hasan-sdaif <[email protected]>
Co-authored-by: Erik Holmbom <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:54:40 -04:00

389 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OPTIONS: the user's preferences, in one dialog and one JSON form.
//!
//! Typed (OPTIONS, OP), chosen from Tools ▸ Options… or, on macOS, CADCraft ▸ Settings… (⌘,),
//! OPTIONS opens the Options dialog ([`dialog`]). A JSON call never opens it: `options {}` reports
//! every preference and `options {"PICKBOX": 5, "modelBackground": "#ffffff"}` changes some (all
//! or nothing; `"reset": true` restores the defaults first). Upper-case names are the engine's
//! profile variables (`cadcraft_engine::cmd::options`); camel-case names are the interface's own:
//!
//! | name | value |
//! |---|---|
//! | `theme` | `"system"`, `"light"` or `"dark"` |
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"` or `"ar"` |
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
//! | `SAVEFILEPATH` | the folder automatic saves go to |
//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu |
//! | `systemCursor` | keep the system cursor visible over the drawing area |
//! | `historyLines` | command-line history lines, 0–12 |
//! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default |
//! | `selectionColor` | the colour selected objects are highlighted in: `"#rrggbb"`, or `null` for the theme's |
//! | `snapMarkerSize` | object snap marker size in points, 5–31 |
//!
//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state
//! rather than in the profile.
//!
//! The profile variables and the interface preferences survive restarts: the host saves
//! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with
//! [`CadApp::load_profile`].
mod dialog;
pub use dialog::dialog;
use egui::Color32;
use serde_json::{Map, Value, json};
use crate::CadApp;
use crate::animation::AnimationPref;
use crate::theme::{self, ThemePref};
/// The host storage key for [`CadApp::profile_json`].
pub const PROFILE_KEY: &str = "cadcraft.profile";
/// The largest saved profile [`CadApp::load_profile`] reads.
const MAX_PROFILE_BYTES: usize = 64 * 1024;
/// The longest SAVEFILEPATH the JSON form accepts.
const MAX_PATH_CHARS: usize = 1024;
/// Object snap marker sizes the Options dialog offers, in points.
pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive<f32> = 5.0..=31.0;
/// The interface's own preferences (the drafting ones are engine settings).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct UiOptions {
/// Model-space background; `None` keeps the theme's.
pub model_background: Option<Color32>,
/// The sheet colour on layout tabs; `None` is white.
pub paper_background: Option<Color32>,
/// Object snap marker size in points.
pub snap_marker_size: f32,
/// The selection highlight colour; `None` keeps the theme's.
pub selection_color: Option<Color32>,
/// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu
/// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu.
pub time_sensitive_right_click: bool,
}
impl Default for UiOptions {
fn default() -> Self {
UiOptions {
model_background: None,
paper_background: None,
snap_marker_size: theme::SNAP_MARKER,
selection_color: None,
time_sensitive_right_click: true,
}
}
}
impl UiOptions {
/// What the drawing area paints with.
pub fn canvas(&self) -> theme::Canvas {
theme::Canvas {
model: self.model_background,
paper: self.paper_background,
snap_marker: self.snap_marker_size,
selection: self.selection_color,
}
}
}
/// `"#rrggbb"`.
pub fn hex(c: Color32) -> String {
format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())
}
/// Parse `"#rrggbb"` (the `#` is optional).
pub fn parse_hex(s: &str) -> Option<Color32> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
return None;
}
let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?))
}
fn color_json(c: Option<Color32>) -> Value {
c.map_or(Value::Null, |c| json!(hex(c)))
}
/// Open the dialog for a typed or menu-invoked OPTIONS; run the JSON form for a call with
/// parameters. `None` if `id` isn't OPTIONS.
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if !matches!(id, "options" | "op" | "preferences") {
return None;
}
if params.is_null() {
app.ui.dialog = Some("options".into());
return Some(Ok(Value::Null));
}
Some(run_json(app, params))
}
/// The interface preferences a JSON call may set, parsed before anything changes.
#[derive(Default)]
struct UiChanges {
theme: Option<ThemePref>,
animations: Option<AnimationPref>,
language: Option<crate::i18n::Preference>,
save_format: Option<crate::SaveFormat>,
system_cursor: Option<bool>,
history_lines: Option<usize>,
model_background: Option<Option<Color32>>,
paper_background: Option<Option<Color32>>,
selection_color: Option<Option<Color32>>,
snap_marker_size: Option<f32>,
save_version: Option<crate::SaveVersion>,
savetime: Option<u32>,
savefilepath: Option<String>,
time_sensitive_right_click: Option<bool>,
}
fn color_param(key: &str, v: &Value) -> Result<Option<Color32>, String> {
match v {
Value::Null => Ok(None),
Value::String(s) => parse_hex(s).map(Some).ok_or_else(|| format!("{key} must be \"#rrggbb\" or null")),
_ => Err(format!("{key} must be \"#rrggbb\" or null")),
}
}
fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
let text = || v.as_str().ok_or_else(|| format!("{key} must be text"));
match key {
"theme" => c.theme = Some(ThemePref::parse(text()?).ok_or("theme must be system, light or dark")?),
"animations" => c.animations = Some(AnimationPref::parse(text()?).ok_or("animations must be system, on or off")?),
"interfaceLanguage" => {
c.language = Some(
crate::i18n::Preference::parse(text()?).ok_or_else(|| format!("interfaceLanguage must be {}", crate::i18n::Preference::codes()))?,
)
}
"saveFormat" => c.save_format = Some(crate::SaveFormat::parse(text()?).ok_or("saveFormat must be dxf or dwg")?),
"systemCursor" => c.system_cursor = Some(v.as_bool().ok_or("systemCursor must be true or false")?),
"historyLines" => {
let n = v.as_u64().filter(|n| *n <= crate::cmdline::MAX_LINES as u64).ok_or("historyLines must be a whole number from 0 to 12")?;
c.history_lines = Some(n as usize);
}
"modelBackground" => c.model_background = Some(color_param(key, v)?),
"paperBackground" => c.paper_background = Some(color_param(key, v)?),
"selectionColor" => c.selection_color = Some(color_param(key, v)?),
"snapMarkerSize" => {
let n = v.as_f64().filter(|n| SNAP_MARKER_SIZES.contains(&(*n as f32)) && n.is_finite()).ok_or("snapMarkerSize must be from 5 to 31")?;
c.snap_marker_size = Some(n as f32);
}
"saveVersion" => {
let name = v.as_str().map_or_else(|| v.to_string(), str::to_string);
c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?);
}
"timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?),
k if k.eq_ignore_ascii_case("SAVETIME") => {
let max = cadcraft_engine::autosave::MAX_SAVETIME;
let n = v
.as_f64()
.filter(|n| n.is_finite() && (0.0..=f64::from(max)).contains(n))
.ok_or_else(|| format!("SAVETIME must be from 0 to {max} minutes"))?;
c.savetime = Some(n.round() as u32);
}
k if k.eq_ignore_ascii_case("SAVEFILEPATH") => {
let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control));
c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string());
}
_ => return Ok(false),
}
Ok(true)
}
/// The interface preferences as the JSON form reports them.
fn ui_json(app: &CadApp) -> Map<String, Value> {
let o = &app.options;
let mut m = Map::new();
m.insert("theme".into(), json!(app.ui.theme.as_str()));
m.insert("animations".into(), json!(app.ui.animations.as_str()));
m.insert("interfaceLanguage".into(), json!(app.ui.interface_language.code()));
m.insert("saveFormat".into(), json!(app.ui.save_format.as_str()));
m.insert("systemCursor".into(), json!(app.ui.system_cursor));
m.insert("historyLines".into(), json!(app.ui.history_lines));
m.insert("modelBackground".into(), color_json(o.model_background));
m.insert("paperBackground".into(), color_json(o.paper_background));
m.insert("selectionColor".into(), color_json(o.selection_color));
m.insert("snapMarkerSize".into(), json!(o.snap_marker_size));
m.insert("saveVersion".into(), json!(app.ui.save_version));
m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime()));
m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath()));
m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click));
m
}
/// Every preference: the interface's and the engine's profile variables.
pub fn report(app: &CadApp) -> Value {
let mut m = ui_json(app);
if let Value::Object(engine) = cadcraft_engine::cmd::options::profile(&app.session) {
m.extend(engine);
}
Value::Object(m)
}
/// The OPTIONS JSON form: validate everything, then change it.
fn run_json(app: &mut CadApp, params: &Value) -> Result<Value, String> {
let Some(obj) = params.as_object() else { return Err("options: expected an object".into()) };
let mut ui = UiChanges::default();
let mut engine = Map::new();
for (k, v) in obj {
if !parse_ui(k, v, &mut ui)? {
engine.insert(k.clone(), v.clone());
}
}
let reset = engine.get("reset").and_then(Value::as_bool) == Some(true);
// The engine checks its part (and refuses unknown names) before anything here changes.
app.session.execute("options", &Value::Object(engine)).map_err(|e| e.to_string())?;
if reset {
reset_ui(app);
}
if let Some(t) = ui.theme {
app.ui.theme = t;
}
if let Some(a) = ui.animations {
app.ui.animations = a;
}
if let Some(l) = ui.language {
app.ui.interface_language = l;
}
if let Some(f) = ui.save_format {
app.ui.save_format = f;
}
if let Some(b) = ui.system_cursor {
app.ui.system_cursor = b;
}
if let Some(n) = ui.history_lines {
app.ui.history_lines = n;
}
if let Some(c) = ui.model_background {
app.options.model_background = c;
}
if let Some(c) = ui.paper_background {
app.options.paper_background = c;
}
if let Some(c) = ui.selection_color {
app.options.selection_color = c;
}
if let Some(n) = ui.snap_marker_size {
app.options.snap_marker_size = n;
}
if let Some(v) = ui.save_version {
app.ui.save_version = v.year().to_string();
}
if let Some(m) = ui.savetime {
cadcraft_engine::autosave::set_savetime(m);
}
if let Some(p) = &ui.savefilepath {
cadcraft_engine::autosave::set_savefilepath(p);
}
if let Some(b) = ui.time_sensitive_right_click {
app.options.time_sensitive_right_click = b;
}
Ok(report(app))
}
/// The interface preferences back to their defaults (the interface language stays).
fn reset_ui(app: &mut CadApp) {
let d = crate::UiState::default();
app.ui.theme = d.theme;
app.ui.animations = d.animations;
app.ui.save_format = d.save_format;
app.ui.save_version = d.save_version;
app.ui.system_cursor = d.system_cursor;
app.ui.history_lines = d.history_lines;
app.options = UiOptions::default();
}
impl CadApp {
/// The user profile kept across restarts, as JSON for the host's storage ([`PROFILE_KEY`]):
/// the engine's profile variables and the interface preferences the interface prefs
/// ([`CadApp::prefs_json`]) and the desktop workspace file (panels, command line) do not
/// already keep.
pub fn profile_json(&self) -> String {
json!({
"version": 1,
"sysvars": cadcraft_engine::cmd::options::profile(&self.session),
"systemCursor": self.ui.system_cursor,
"modelBackground": color_json(self.options.model_background),
"paperBackground": color_json(self.options.paper_background),
"selectionColor": color_json(self.options.selection_color),
"snapMarkerSize": self.options.snap_marker_size,
"timeSensitiveRightClick": self.options.time_sensitive_right_click,
})
.to_string()
}
/// Restore a profile saved by [`Self::profile_json`]. Unknown, malformed or out-of-range
/// entries are skipped; a profile from a newer version still restores what it can.
pub fn load_profile(&mut self, json: &str) {
if json.len() > MAX_PROFILE_BYTES {
return;
}
let Ok(v) = serde_json::from_str::<Value>(json) else { return };
if let Some(vars) = v.get("sysvars") {
cadcraft_engine::cmd::options::restore(&mut self.session, vars);
}
let mut c = UiChanges::default();
for key in ["systemCursor", "modelBackground", "paperBackground", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick"] {
if let Some(value) = v.get(key) {
let _ = parse_ui(key, value, &mut c);
}
}
// CADCRAFT_SYSTEM_CURSOR=1 turns the system cursor on whatever was saved.
if c.system_cursor == Some(true) {
self.ui.system_cursor = true;
}
if let Some(bg) = c.model_background {
self.options.model_background = bg;
}
if let Some(bg) = c.paper_background {
self.options.paper_background = bg;
}
if let Some(sel) = c.selection_color {
self.options.selection_color = sel;
}
if let Some(n) = c.snap_marker_size {
self.options.snap_marker_size = n;
}
if let Some(b) = c.time_sensitive_right_click {
self.options.time_sensitive_right_click = b;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_colours_round_trip_and_bad_ones_are_refused() {
let c = Color32::from_rgb(0x12, 0xab, 0xff);
assert_eq!(parse_hex(&hex(c)), Some(c));
assert_eq!(parse_hex("FFFFFF"), Some(Color32::WHITE));
for bad in ["", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] {
assert_eq!(parse_hex(bad), None, "{bad}");
}
}
#[test]
fn dialog_labels_have_ukrainian_translations() {
let sources = [include_str!("dialog.rs")];
let mut missing: Vec<String> = dialog::TABS.iter().filter(|(_, l)| crate::i18n::tr("uk", l) == *l).map(|(_, l)| l.to_string()).collect();
for source in sources {
for rest in source.split("crate::tl!(").skip(1) {
let Some(text) = rest.strip_prefix('"').and_then(|r| r.split('"').next()) else { continue };
// File format names read the same in every language.
if crate::i18n::tr("uk", text) == text && !["DXF", "DWG"].contains(&text) {
missing.push(text.to_string());
}
}
}
assert!(missing.is_empty(), "missing translations: {missing:?}");
}
}