diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6cb71793..bbdb6900 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -51,3 +51,9 @@ Generated-in-code assets are original and have no file to list: | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | | `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | | `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | + +## Interface translations + +| Asset | Author | Source | Licence | Notes | +|---|---|---|---|---| +| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | diff --git a/Cargo.lock b/Cargo.lock index 4c95b3de..4337ad05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ dependencies = [ "muda", "rfd", "serde_json", + "sys-locale", "winit", "winresource", ] @@ -3382,6 +3383,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/README.md b/README.md index 4e8c8a4b..b98da27e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ numbers): | Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF | | Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit | | Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror | +| Interface languages | English and Ukrainian (Українська), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) | | Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) | ## Quick start diff --git a/ROADMAP.md b/ROADMAP.md index b84f3bca..bf055a17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,7 +73,7 @@ edge cases, dialogs), which is why it is lower. | M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~70% (GPU canvas, incremental R-tree: pick 50 ms → 0.001 ms at 200k entities, ≈ 4 ms after an edit) | 12 | | M11 | 3D: UCS, orbit, visual styles, solids (box…loft, booleans, fillet edges), meshes, surfaces, sections, rendering | 0% | 200 | | M12 | Automation: an embedded safe AutoLISP-compatible interpreter, action recorder, sheet sets, CLI/MCP parity tests | ~25% | 60 | -| M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% | 40 | +| M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% (English/Ukrainian interface catalogs, live language switching and persisted system-language choice) | 40 | | | **Remaining total** | | **≈ 570 hours** | At roughly 570 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index c966303b..c89e1272 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -22,7 +22,7 @@ log = { workspace = true } serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator"] } diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 4f4007a1..6810ccea 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -25,6 +25,13 @@ mod web { self.0.raw_input_hook(raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } + /// Preferences (interface theme and language) survive reloads; egui state is not kept. + fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); + } + fn persist_egui_memory(&self) -> bool { + false + } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } @@ -59,6 +66,11 @@ mod web { options, Box::new(move |cc| { let mut app = CadApp::new(Session::new(), Services::default()); + app.system_languages = + web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default(); + if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { + app.load_prefs(&prefs); + } if let Some(rs) = &cc.wgpu_render_state { app.set_wgpu(rs); } diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index a59177f4..90b55b22 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -27,6 +27,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 3da95cdf..3ae72eb3 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -22,7 +22,7 @@ mod logging; mod native_menu; use cadcraft_engine::Session; -use cadcraft_ui_egui::{CadApp, Services}; +use cadcraft_ui_egui::{CadApp, Services, i18n}; struct App(CadApp, #[cfg(target_os = "macos")] Option); @@ -71,8 +71,8 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Drawings (DWG, DXF)", &["dwg", "dxf"]) - .add_filter("All files", &["*"]) + .add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf"]) + .add_filter(i18n::t("All files"), &["*"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) })), @@ -83,7 +83,7 @@ fn services() -> Services { filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}"))); filters .iter() - .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(*label, &[*ext])) + .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext])) .save_file() .map(|p| p.to_string_lossy().to_string()) })), @@ -212,6 +212,7 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = CadApp::new(Session::empty(), services()); + app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect()); if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index dfb8c905..be1c6905 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -4,14 +4,17 @@ use std::collections::HashMap; use std::str::FromStr; -use cadcraft_ui_egui::CadApp; use cadcraft_ui_egui::menus::{self, Entry}; +use cadcraft_ui_egui::{CadApp, i18n}; use muda::accelerator::Accelerator; use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; pub struct NativeMenu { _menu: Menu, - items: HashMap, + items: HashMap, + submenus: Vec<(String, Submenu)>, + predefined: Vec<(&'static str, PredefinedMenuItem)>, + language: &'static str, last_refresh: f64, } @@ -27,37 +30,42 @@ impl NativeMenu { pub fn install(app: &mut CadApp) -> Self { let menu = Menu::new(); let mut items = HashMap::new(); + let language = app.language(); + let mut submenus = Vec::new(); let mut counter = 0usize; let app_menu = Submenu::new("CADCraft", true); - let about = MenuItem::with_id("cc-about", "About CADCraft", true, None); - let discord = MenuItem::with_id("cc-discord", "Join the ArtCraft Discord…", true, None); - let _ = app_menu.append_items(&[ - &about, - &discord, - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::services(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::hide(None), - &PredefinedMenuItem::hide_others(None), - &PredefinedMenuItem::show_all(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), - ]); - items.insert("cc-about".into(), ("ui.dialog.about".into(), about)); - items.insert("cc-discord".into(), ("ui.discord".into(), discord)); + let about = MenuItem::with_id("cc-about", i18n::tr(language, "About CADCraft"), true, None); + let discord = MenuItem::with_id("cc-discord", i18n::tr(language, "Join the ArtCraft Discord…"), true, None); + let predefined = vec![ + ("Services", PredefinedMenuItem::services(Some(i18n::tr(language, "Services")))), + ("Hide CADCraft", PredefinedMenuItem::hide(Some(i18n::tr(language, "Hide CADCraft")))), + ("Hide Others", PredefinedMenuItem::hide_others(Some(i18n::tr(language, "Hide Others")))), + ("Show All", PredefinedMenuItem::show_all(Some(i18n::tr(language, "Show All")))), + ("Quit CADCraft", PredefinedMenuItem::quit(Some(i18n::tr(language, "Quit CADCraft")))), + ]; + let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator()]); + for (label, item) in &predefined { + let _ = app_menu.append(item); + if ["Services", "Show All"].contains(label) { + let _ = app_menu.append(&PredefinedMenuItem::separator()); + } + } + items.insert("cc-about".into(), ("ui.dialog.about".into(), "About CADCraft".into(), about)); + items.insert("cc-discord".into(), ("ui.discord".into(), "Join the ArtCraft Discord…".into(), discord)); let _ = menu.append(&app_menu); for (title, entries) in menus::tree(app) { - let sub = Submenu::new(&title, true); - build(&sub, &entries, &mut items, &mut counter); + let sub = Submenu::new(i18n::tr(language, &title), true); + build(&sub, &entries, &mut items, &mut submenus, &mut counter, language); let _ = menu.append(&sub); + submenus.push((title, sub)); } menu.init_for_nsapp(); - Self { _menu: menu, items, last_refresh: 0.0 } + Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 } } pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { while let Ok(ev) = MenuEvent::receiver().try_recv() { - if let Some((cmd, _)) = self.items.get(ev.id.as_ref()) { + if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) { if cmd == "ui.discord" { ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft")); } else { @@ -66,32 +74,53 @@ impl NativeMenu { ctx.request_repaint(); } } + let language = app.language(); + if self.language != language { + self.language = language; + for (_, label, item) in self.items.values() { + item.set_text(i18n::tr(language, label)); + } + for (label, item) in &self.predefined { + item.set_text(i18n::tr(language, label)); + } + for (label, submenu) in &self.submenus { + submenu.set_text(i18n::tr(language, label)); + } + } let now = cadcraft_ui_egui::now_ms(); if now - self.last_refresh < 300.0 { return; } self.last_refresh = now; - for (cmd, item) in self.items.values() { + for (cmd, _, item) in self.items.values() { let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); item.set_enabled(en); } } } -fn build(parent: &Submenu, entries: &[Entry], items: &mut HashMap, counter: &mut usize) { +fn build( + parent: &Submenu, + entries: &[Entry], + items: &mut HashMap, + submenus: &mut Vec<(String, Submenu)>, + counter: &mut usize, + language: &str, +) { for e in entries { match e { Entry::Sub { label, children } => { - let sub = Submenu::new(label, true); - build(&sub, children, items, counter); + let sub = Submenu::new(i18n::tr(language, label), true); + build(&sub, children, items, submenus, counter, language); let _ = parent.append(&sub); + submenus.push((label.clone(), sub)); } Entry::Item { label, id, shortcut, enabled } => { *counter += 1; let mid = format!("cc{counter}"); - let i = MenuItem::with_id(mid.clone(), label, *enabled, shortcut.as_deref().and_then(accel)); + let i = MenuItem::with_id(mid.clone(), i18n::tr(language, label), *enabled, shortcut.as_deref().and_then(accel)); let _ = parent.append(&i); - items.insert(mid, (id.clone(), i)); + items.insert(mid, (id.clone(), label.clone(), i)); } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4744ae24..7be3ea16 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -451,8 +451,8 @@ fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { p.line(vec![at + vec2(-s, -s), at + vec2(s, -s), at + vec2(-s, s), at + vec2(s, s), at + vec2(-s, -s)], st); } } - p.text(at + vec2(12.0, 12.0), egui::Align2::LEFT_TOP, hit.name, crate::theme::small(), Color32::BLACK); - let galley = p.layout_no_wrap(hit.name.to_string(), crate::theme::small(), Color32::BLACK); + p.text(at + vec2(12.0, 12.0), egui::Align2::LEFT_TOP, crate::i18n::t(hit.name), crate::theme::small(), Color32::BLACK); + let galley = p.layout_no_wrap(crate::i18n::t(hit.name).to_string(), crate::theme::small(), Color32::BLACK); let r = Rect::from_min_size(at + vec2(10.0, 10.0), galley.size() + vec2(6.0, 2.0)); p.rect_filled(r, 2.0, Color32::from_rgb(0xff, 0xff, 0xe1)); p.galley(r.min + vec2(3.0, 1.0), galley, Color32::BLACK); @@ -881,7 +881,11 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let far = base + Vec2::from_angle(a) * (view.height * 4.0); let pts = [xf.to_screen(base), xf.to_screen(far)]; painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_rgb(0x4c, 0xd1, 0x37)), 3.0, 3.0)); - let tip = format!("Polar: {} < {}°", cadcraft_engine::units::format_distance(base.dist(c), 2, 4), (a.to_degrees().round() as i64)); + let tip = crate::tf!( + "Polar: {distance} < {angle}°", + distance = cadcraft_engine::units::format_distance(base.dist(c), 2, 4), + angle = a.to_degrees().round() as i64 + ); tooltip(&painter, xf.to_screen(c) + vec2(16.0, 18.0), &tip); } if let (Some(hit), Some(_)) = (app.canvas.snap, app.canvas.cursor) { @@ -948,23 +952,23 @@ fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) { && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) && let Some(c) = app.canvas.cursor { - dynamic_input_boxes(p, at, &prompt.message, &f, &e, c); + dynamic_input_boxes(p, at, crate::i18n::t(&prompt.message), &f, &e, c); return; } - let mut text = prompt.message.clone(); + let mut text = crate::i18n::t(&prompt.message).to_string(); if !app.cmd.buffer.is_empty() { - text = format!("{}: {}", prompt.message, app.cmd.buffer); + text = format!("{}: {}", crate::i18n::t(&prompt.message), app.cmd.buffer); } else if let (Some(base), Some(c)) = (prompt.base, app.canvas.cursor) { text = format!( "{} {} < {}°", - prompt.message, + crate::i18n::t(&prompt.message), cadcraft_engine::units::format_distance(base.dist(c), 2, 4), (base.angle_to(c).to_degrees() * 10.0).round() / 10.0 ); } else if let Some(c) = app.canvas.cursor { text = format!( "{} {}, {}", - prompt.message, + crate::i18n::t(&prompt.message), cadcraft_engine::units::format_distance(c.x, 2, 4), cadcraft_engine::units::format_distance(c.y, 2, 4) ); @@ -1083,14 +1087,14 @@ fn draw_ucs_icon(p: &egui::Painter, rect: Rect) { p.line_segment([o, o + vec2(0.0, -60.0)], st); p.rect_stroke(Rect::from_center_size(o, vec2(9.0, 9.0)), 0.0, st, egui::StrokeKind::Middle); let f = egui::FontId::proportional(13.0); - p.text(o + vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, "X", f.clone(), t.canvas_ink); - p.text(o + vec2(0.0, -70.0), egui::Align2::CENTER_BOTTOM, "Y", f, t.canvas_ink); + p.text(o + vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, crate::tl!("X"), f.clone(), t.canvas_ink); + p.text(o + vec2(0.0, -70.0), egui::Align2::CENTER_BOTTOM, crate::tl!("Y"), f, t.canvas_ink); } fn viewport_label(p: &egui::Painter, rect: Rect) { let t = Tokens::get(); let at = pos2(rect.left() + 10.0, rect.top() + 8.0); - p.text(at, egui::Align2::LEFT_TOP, "+ | Top | 2D Wireframe", crate::theme::small(), t.canvas_ink); + p.text(at, egui::Align2::LEFT_TOP, crate::tl!("+ | Top | 2D Wireframe"), crate::theme::small(), t.canvas_ink); } fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { @@ -1102,7 +1106,7 @@ fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { // WCS pill. let pill = Rect::from_center_size(c + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)); p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); - p.text(pill.center(), egui::Align2::CENTER_CENTER, "WCS ⌄", crate::theme::small(), t.canvas_ink); + p.text(pill.center(), egui::Align2::CENTER_CENTER, crate::tl!("WCS ⌄"), crate::theme::small(), t.canvas_ink); } /// The grip of a selected object under the cursor, if any. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e2b41c67..98871d1c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -48,7 +48,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { if title_h > 0.0 { let title = match app.session.state() { Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }), - _ => "CADCraft Start".into(), + _ => format!("CADCraft {}", crate::tl!("Start")), }; ui.painter().text( pos2(r.center().x, r.top() + title_h / 2.0 + 1.0), @@ -83,8 +83,8 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { } icons::paint(ui.painter(), br.shrink(4.0), *icon, false); let tip = match shortcut { - Some(sc) => format!("{label} ({})", crate::menus::shortcut_label(sc, mac)), - None => (*label).to_string(), + Some(sc) => format!("{} ({})", crate::i18n::t(label), crate::menus::shortcut_label(sc, mac)), + None => crate::i18n::t(label).to_string(), }; if resp.on_hover_text(tip).clicked() { clicked = Some(*cmd); @@ -112,7 +112,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click()); icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false); - if presp.on_hover_text("New Drawing").clicked() { + if presp.on_hover_text(crate::tl!("New Drawing")).clicked() { app.start("new"); app.ui.start_tab = false; } @@ -149,7 +149,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { x += w; (resp.clicked(), close) }; - let (c, _) = tab(ui, "Start", app.ui.start_tab || app.session.docs.is_empty(), 110.0, ui.id().with("tab_start")); + let (c, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), 110.0, ui.id().with("tab_start")); if c { app.ui.start_tab = true; } @@ -199,7 +199,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); - if resp.on_hover_text(tip).clicked() && icon == Icon::Plus { + if resp.on_hover_text(crate::i18n::t(tip)).clicked() && icon == Icon::Plus { let _ = app.run("layout.new", json!({})); } x += 22.0; @@ -218,7 +218,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { } let mut switch = None; for (i, name) in tabs.iter().enumerate() { - let g = p.layout_no_wrap(name.clone(), crate::theme::body(), t.text); + let g = p.layout_no_wrap(if i == 0 { crate::tl!("Model").to_string() } else { name.clone() }, crate::theme::body(), t.text); let w = g.size().x + 26.0; let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)); let active = cur.as_deref().is_ok_and(|c| c == name); @@ -237,7 +237,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); - if presp.on_hover_text("New layout").clicked() + if presp.on_hover_text(crate::tl!("New layout")).clicked() && let Ok(v) = app.run("layout.new", json!({})) && let Some(n) = v.get("name").and_then(serde_json::Value::as_str) { @@ -289,8 +289,8 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0)); let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click()); p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) }); - p.text(br.center(), egui::Align2::CENTER_CENTER, label, crate::theme::small(), t.text); - if resp.on_hover_text("Switch between model space in a viewport and paper space").clicked() { + p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text); + if resp.on_hover_text(crate::tl!("Switch between model space in a viewport and paper space")).clicked() { let _ = app.run(if model { "pspace" } else { "mspace" }, json!({})); app.canvas.list = None; } @@ -314,7 +314,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { // Re-tint the icon blue-ish by an underline. p.hline(br.x_range().shrink(4.0), br.bottom() - 1.0, Stroke::new(1.5, t.toggle_on)); } - if resp.on_hover_text(tip).clicked() { + if resp.on_hover_text(crate::i18n::t(tip)).clicked() { toggle_cmd = Some(cmd); } rx += size + 4.0; @@ -336,42 +336,42 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); let r = ui.max_rect(); let p = ui.painter().clone(); - let left = Rect::from_min_size(r.min, vec2(260.0, r.height())); + let left = Rect::from_min_size(r.min, vec2(300.0, r.height())); p.rect_filled(left, 0.0, t.chrome_dark); p.rect_filled(Rect::from_min_max(pos2(left.right(), r.top()), r.max), 0.0, t.chrome); - p.text(pos2(left.left() + 34.0, left.top() + 70.0), egui::Align2::LEFT_CENTER, "CADCraft", egui::FontId::proportional(28.0), t.text); + p.text(pos2(left.left() + 34.0, left.top() + 70.0), egui::Align2::LEFT_CENTER, crate::tl!("CADCraft"), egui::FontId::proportional(28.0), t.text); p.text( pos2(left.left() + 34.0, left.top() + 98.0), egui::Align2::LEFT_CENTER, - format!("Version {}", env!("CARGO_PKG_VERSION")), + crate::tf!("Version {version}", version = env!("CARGO_PKG_VERSION")), crate::theme::small(), t.text_faint, ); let mut y = left.top() + 140.0; let mut action = None; for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] { - let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(190.0, 34.0)); + let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0)); let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click()); p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); - p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, label, crate::theme::body(), t.text); + p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text); if resp.clicked() { action = Some(cmd); } y += 44.0; } let main = Rect::from_min_max(pos2(left.right() + 36.0, r.top() + 40.0), r.max); - p.text(main.min, egui::Align2::LEFT_TOP, "Recent", egui::FontId::proportional(22.0), t.text); + p.text(main.min, egui::Align2::LEFT_TOP, crate::tl!("Recent"), egui::FontId::proportional(22.0), t.text); p.text( pos2(main.center().x, main.center().y), egui::Align2::CENTER_CENTER, - "There is nothing here yet.", + crate::tl!("There is nothing here yet."), egui::FontId::proportional(18.0), t.text, ); p.text( pos2(main.center().x, main.center().y + 26.0), egui::Align2::CENTER_CENTER, - "To get started, create or open a drawing.", + crate::tl!("To get started, create or open a drawing."), crate::theme::body(), t.text_dim, ); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 48bbf45b..51681180 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -288,7 +288,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let prompt = app.session.current_prompt(); let font = crate::theme::body(); if let Some(g) = app.canvas.hot_grip { - let gl = p.layout_no_wrap(g.label().to_string(), font.clone(), t.text); + let gl = p.layout_no_wrap(crate::i18n::t(g.label()).to_string(), font.clone(), t.text); let gw = gl.size().x; p.galley(pos2(x, bar.center().y - gl.size().y / 2.0), gl, t.text); x += gw + 6.0; @@ -300,12 +300,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); x += gw; - let g = p.layout_no_wrap(pr.message.clone(), font.clone(), t.text); + let g = p.layout_no_wrap(crate::i18n::t(&pr.message).to_string(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; if !pr.keywords.is_empty() { - let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { " or [".into() }, font.clone(), t.text); + let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; @@ -348,7 +348,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } None => { if app.cmd.buffer.is_empty() { - p.text(pos2(x, bar.center().y), egui::Align2::LEFT_CENTER, "Type a command", egui::FontId::proportional(12.5), t.text_faint); + p.text( + pos2(x, bar.center().y), + egui::Align2::LEFT_CENTER, + crate::tl!("Type a command"), + egui::FontId::proportional(12.5), + t.text_faint, + ); } } } @@ -389,7 +395,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row }); } p.text(Pos2::new(r.left() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, id, crate::theme::body(), t.text); - p.text(Pos2::new(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, *label, crate::theme::small(), t.text_faint); + p.text( + Pos2::new(r.right() - 8.0, r.center().y), + egui::Align2::RIGHT_CENTER, + crate::i18n::t(label), + crate::theme::small(), + t.text_faint, + ); if resp.clicked() { app.cmd.buffer.clear(); app.start(&id.to_ascii_lowercase()); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 765dec19..2385c61b 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -215,39 +215,47 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let id = egui::Id::new("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label("Show"); + ui.label(crate::tl!("Show")); for m in NameMode::ALL { - if ui.selectable_label(v.names == m, m.label()).clicked() { + if ui.selectable_label(v.names == m, crate::i18n::t(m.label())).clicked() { v.names = m; } } ui.separator(); - ui.label("Sort"); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + ui.label(crate::tl!("Sort")); + egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - if ui.selectable_label(v.key == k, k.label().0).clicked() { + if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() { v.key = k; v.ascending = k.default_ascending(); } } }); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::tl!("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); - if ui.selectable_label(!v.table, "Grab bag").clicked() { + if ui.selectable_label(!v.table, crate::tl!("Grab bag")).clicked() { v.table = false; } - if ui.selectable_label(v.table, "Table").clicked() { + if ui.selectable_label(v.table, crate::tl!("Table")).clicked() { v.table = true; } }); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); - ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); + ui.label( + RichText::new(crate::tf!( + "{contributors} · {commits}", + contributors = crate::i18n::count(list.len() as u64, "{n} contributor", "{n} contributors"), + commits = crate::i18n::count(TOTAL_COMMITS, "{n} commit", "{n} commits") + )) + .small() + .weak(), + ); ui.separator(); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { if list.is_empty() { - ui.label("No contributor data was built into this copy."); + ui.label(crate::tl!("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v); } else { @@ -268,7 +276,11 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { + if ui + .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong()) + .on_hover_text(crate::i18n::t(k.label().0)) + .clicked() + { if v.key == k { v.ascending = !v.ascending; } else { @@ -297,14 +309,14 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { /// About ▸ Models: AI models credited in Co-Authored-By trailers. pub fn models_ui(ui: &mut egui::Ui) { if MODELS.is_empty() { - ui.label("No model credits were built into this copy."); + ui.label(crate::tl!("No model credits were built into this copy.")); return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { - ui.label(RichText::new(h).strong()); + ui.label(RichText::new(crate::i18n::t(h)).strong()); } ui.end_row(); for m in MODELS { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 356b35e3..060504ac 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let Some(d) = app.ui.dialog.clone() else { return }; let mut open = true; match d.as_str() { + "language" => language(app, ctx, &mut open), "layers" => crate::layers::dialog(app, ctx, &mut open), "qselect" => crate::quick::qselect_dialog(app, ctx, &mut open), "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open), @@ -30,29 +31,29 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { } fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new("Drafting Settings").open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| { let s = &mut app.session.settings; - ui.heading("Snap and Grid"); - ui.checkbox(&mut s.snapmode, "Snap On (F9)"); + ui.heading(crate::tl!("Snap and Grid")); + ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)")); ui.horizontal(|ui| { - ui.label("Snap X spacing"); + ui.label(crate::tl!("Snap X spacing")); ui.add(egui::DragValue::new(&mut s.snapunit.x).speed(0.05).range(0.0001..=1e6)); - ui.label("Y"); + ui.label(crate::tl!("Y")); ui.add(egui::DragValue::new(&mut s.snapunit.y).speed(0.05).range(0.0001..=1e6)); }); - ui.checkbox(&mut s.gridmode, "Grid On (F7)"); + ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)")); ui.horizontal(|ui| { - ui.label("Grid spacing"); + ui.label(crate::tl!("Grid spacing")); ui.add(egui::DragValue::new(&mut s.gridunit.x).speed(0.05).range(0.0001..=1e6)); - ui.label("Major line every"); + ui.label(crate::tl!("Major line every")); ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)); }); ui.separator(); - ui.heading("Polar Tracking"); - ui.checkbox(&mut s.polarmode, "Polar Tracking On (F10)"); + ui.heading(crate::tl!("Polar Tracking")); + ui.checkbox(&mut s.polarmode, crate::tl!("Polar Tracking On (F10)")); let mut deg = s.polarang.to_degrees(); ui.horizontal(|ui| { - ui.label("Increment angle"); + ui.label(crate::tl!("Increment angle")); egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| { for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] { ui.selectable_value(&mut deg, a, format!("{a}")); @@ -61,9 +62,9 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); s.polarang = deg.to_radians(); ui.separator(); - ui.heading("Object Snap"); + ui.heading(crate::tl!("Object Snap")); let mut on = s.osmode & cadcraft_engine::snap::mode::OFF == 0; - if ui.checkbox(&mut on, "Object Snap On (F3)").changed() { + if ui.checkbox(&mut on, crate::tl!("Object Snap On (F3)")).changed() { if on { s.osmode &= !cadcraft_engine::snap::mode::OFF; } else { @@ -73,7 +74,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { egui::Grid::new("osnap_grid").num_columns(2).show(ui, |ui| { for (i, (bit, name)) in cadcraft_engine::snap::mode::ALL.iter().enumerate() { let mut v = s.osmode & bit != 0; - if ui.checkbox(&mut v, *name).changed() { + if ui.checkbox(&mut v, crate::i18n::t(name)).changed() { if v { s.osmode |= bit; } else { @@ -86,22 +87,22 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } }); ui.separator(); - ui.checkbox(&mut s.dynmode, "Enable Dynamic Input (F12)"); + ui.checkbox(&mut s.dynmode, crate::tl!("Enable Dynamic Input (F12)")); // Pointer input format for second and next points (DYNPIFORMAT, DYNPICOORDS). ui.indent("dynpi", |ui| { ui.add_enabled_ui(s.dynmode, |ui| { - ui.label("Second and next points:"); + ui.label(crate::tl!("Second and next points:")); ui.horizontal(|ui| { - ui.radio_value(&mut s.dynpi_cartesian, false, "Polar (distance < angle)"); - ui.radio_value(&mut s.dynpi_cartesian, true, "Cartesian (x, y)"); + ui.radio_value(&mut s.dynpi_cartesian, false, crate::tl!("Polar (distance < angle)")); + ui.radio_value(&mut s.dynpi_cartesian, true, crate::tl!("Cartesian (x, y)")); }); ui.horizontal(|ui| { - ui.radio_value(&mut s.dynpi_absolute, false, "Relative"); - ui.radio_value(&mut s.dynpi_absolute, true, "Absolute"); + ui.radio_value(&mut s.dynpi_absolute, false, crate::tl!("Relative")); + ui.radio_value(&mut s.dynpi_absolute, true, crate::tl!("Absolute")); }); }); }); - ui.checkbox(&mut s.orthomode, "Ortho (F8)"); + ui.checkbox(&mut s.orthomode, crate::tl!("Ortho (F8)")); }); } @@ -219,62 +220,74 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn about(ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); let tab_id = egui::Id::new("about_tab"); - egui::Window::new("About CADCraft").open(open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| { - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal(|ui| { - for (i, l) in ["About", "Contributors", "Models"].iter().enumerate() { - if ui.selectable_label(tab == i as u8, *l).clicked() { - tab = i as u8; + egui::Window::new(crate::tl!("About CADCraft")) + .id(egui::Id::new("About CADCraft")) + .open(open) + .default_size(vec2(640.0, 420.0)) + .collapsible(false) + .show(ctx, |ui| { + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal(|ui| { + for (i, l) in ["About", "Contributors", "Models"].iter().enumerate() { + if ui.selectable_label(tab == i as u8, crate::i18n::t(l)).clicked() { + tab = i as u8; + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.heading(crate::tl!("CADCraft")); + ui.label(crate::tf!("Version {version}", version = env!("CARGO_PKG_VERSION"))); + ui.label(crate::tl!("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust.")); + ui.add_space(6.0); + ui.label(RichText::new(crate::tl!("One of the Crafting Apps by the ArtCraft team and community.")).color(t.text_dim)); + ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft"); + ui.hyperlink_to(crate::tl!("Join us on Discord"), "https://discord.gg/artcraft"); + ui.add_space(6.0); + ui.label(RichText::new(crate::tl!("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.")).small().color(t.text_faint)); } } }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.heading("CADCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.label("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust."); - ui.add_space(6.0); - ui.label(RichText::new("One of the Crafting Apps by the ArtCraft team and community.").color(t.text_dim)); - ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft"); - ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft"); - ui.add_space(6.0); - ui.label(RichText::new("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.").small().color(t.text_faint)); - } - } - }); } fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut start = None; - egui::Window::new("Command Reference").open(open).default_size(vec2(640.0, 480.0)).show(ctx, |ui| { - let id = ui.id().with("cmdfilter"); - let mut filter = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); - ui.horizontal(|ui| { - ui.label("Filter"); - ui.text_edit_singleline(&mut filter); - }); - ui.data_mut(|d| d.insert_temp(id, filter.clone())); - let f = filter.to_ascii_lowercase(); - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("cmdref").striped(true).num_columns(3).show(ui, |ui| { - for c in cadcraft_engine::command_specs() { - if !f.is_empty() && !c.id.contains(&f) && !c.label.to_ascii_lowercase().contains(&f) { - continue; - } - if ui.link(c.id.to_ascii_uppercase()).clicked() { - start = Some(c.id); - } - ui.label(c.label); - ui.label(RichText::new(if c.aliases.is_empty() { String::new() } else { c.aliases.join(", ").to_ascii_uppercase() }).small()); - ui.end_row(); - } + egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show( + ctx, + |ui| { + let id = ui.id().with("cmdfilter"); + let mut filter = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut filter); }); - }); - }); + ui.data_mut(|d| d.insert_temp(id, filter.clone())); + let f = filter.to_lowercase(); + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("cmdref").striped(true).num_columns(3).show(ui, |ui| { + for c in cadcraft_engine::command_specs() { + if !f.is_empty() + && !c.id.contains(&f) + && !c.label.to_ascii_lowercase().contains(&f) + && !crate::i18n::t(c.label).to_lowercase().contains(&f) + { + continue; + } + if ui.link(c.id.to_ascii_uppercase()).clicked() { + start = Some(c.id); + } + ui.label(crate::i18n::t(c.label)); + ui.label(RichText::new(if c.aliases.is_empty() { String::new() } else { c.aliases.join(", ").to_ascii_uppercase() }).small()); + ui.end_row(); + } + }); + }); + }, + ); if let Some(c) = start { app.ui.dialog = None; app.start(c); @@ -283,14 +296,14 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut pick = None; - egui::Window::new("Blocks").open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { + egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { let Ok(d) = app.session.doc() else { return }; let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect(); if names.is_empty() { - ui.label("No blocks defined in this drawing."); + ui.label(crate::tl!("No blocks defined in this drawing.")); } for n in names { - if ui.button(n).on_hover_text("Insert this block").clicked() { + if ui.button(n).on_hover_text(crate::tl!("Insert this block")).clicked() { pick = Some(n.clone()); } } @@ -324,46 +337,54 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { let mut buf = ctx.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); let mut submit = None; let mut cancel = false; - egui::Window::new("Text Editor").collapsible(false).resizable(true).default_size(vec2(460.0, 220.0)).show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label("Style: Standard"); - ui.separator(); - let h = app.session.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); - ui.label(format!("Height: {h:.4}")); - ui.separator(); - if ui.button("B").on_hover_text("Bold").clicked() { - buf.push_str("{\\fArial|b1;}"); - } - if ui.button("⅟").on_hover_text("Stack (type 1/2 then select)").clicked() { - buf.push_str("\\S1/2;"); - } - if ui.button("°").on_hover_text("Degree").clicked() { - buf.push_str("%%d"); - } - if ui.button("±").on_hover_text("Plus/minus").clicked() { - buf.push_str("%%p"); - } - if ui.button("⌀").on_hover_text("Diameter").clicked() { - buf.push_str("%%c"); + egui::Window::new(crate::tl!("Text Editor")) + .id(egui::Id::new("Text Editor")) + .collapsible(false) + .resizable(true) + .default_size(vec2(460.0, 220.0)) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(crate::tl!("Style: Standard")); + ui.separator(); + let h = app.session.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); + ui.label(crate::tf!("Height: {height}", height = format!("{h:.4}"))); + ui.separator(); + if ui.button(crate::tl!("B")).on_hover_text(crate::tl!("Bold")).clicked() { + buf.push_str("{\\fArial|b1;}"); + } + if ui.button("⅟").on_hover_text(crate::tl!("Stack (type 1/2 then select)")).clicked() { + buf.push_str("\\S1/2;"); + } + if ui.button("°").on_hover_text(crate::tl!("Degree")).clicked() { + buf.push_str("%%d"); + } + if ui.button("±").on_hover_text(crate::tl!("Plus/minus")).clicked() { + buf.push_str("%%p"); + } + if ui.button("⌀").on_hover_text(crate::tl!("Diameter")).clicked() { + buf.push_str("%%c"); + } + }); + let r = ui.add( + egui::TextEdit::multiline(&mut buf) + .desired_rows(6) + .desired_width(f32::INFINITY) + .hint_text(crate::tl!("Type text; Enter starts a new paragraph")), + ); + if !r.has_focus() && buf.is_empty() { + r.request_focus(); } + ui.horizontal(|ui| { + if ui.button(crate::tl!("OK")).clicked() || (r.has_focus() && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))) { + submit = Some(buf.replace('\n', "\\P")); + } + if ui.button(crate::tl!("Cancel")).clicked() { + cancel = true; + } + let finish = if ui.ctx().os().is_mac() { crate::tl!("⌘↩ to finish") } else { crate::tl!("Ctrl+Enter to finish") }; + ui.label(RichText::new(finish).small()); + }); }); - let r = ui.add( - egui::TextEdit::multiline(&mut buf).desired_rows(6).desired_width(f32::INFINITY).hint_text("Type text; Enter starts a new paragraph"), - ); - if !r.has_focus() && buf.is_empty() { - r.request_focus(); - } - ui.horizontal(|ui| { - if ui.button("OK").clicked() || (r.has_focus() && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))) { - submit = Some(buf.replace('\n', "\\P")); - } - if ui.button("Cancel").clicked() { - cancel = true; - } - let finish = if ui.ctx().os().is_mac() { "⌘↩ to finish" } else { "Ctrl+Enter to finish" }; - ui.label(RichText::new(finish).small()); - }); - }); ctx.data_mut(|d| d.insert_temp(id, buf)); if let Some(t) = submit { ctx.data_mut(|d| d.remove::(id)); @@ -374,6 +395,19 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { } } +fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| { + egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| { + for preference in crate::i18n::Preference::ALL { + if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() { + let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()})); + ui.ctx().request_repaint(); + } + } + }); + }); +} + #[cfg(test)] mod tests { use cadcraft_engine::doc::EntityKind; diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs new file mode 100644 index 00000000..c125a45e --- /dev/null +++ b/crates/ui-egui/src/i18n/mod.rs @@ -0,0 +1,186 @@ +//! Display-time localization, following the sibling Craft apps' TSV convention. +//! English strings remain lookup keys; command IDs, drawing data and automation stay unchanged. +//! Add a catalog, a `LANGUAGES` row and a preference variant to introduce another language. + +use std::cell::Cell; +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Preference { + #[default] + #[serde(rename = "auto")] + Auto, + #[serde(rename = "en")] + English, + #[serde(rename = "uk")] + Ukrainian, +} + +impl Preference { + pub const ALL: [Self; 3] = [Self::Auto, Self::English, Self::Ukrainian]; + + pub fn code(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::English => "en", + Self::Ukrainian => "uk", + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Auto => t("Auto (system language)"), + Self::English => "English", + Self::Ukrainian => "Українська", + } + } + + pub fn parse(code: &str) -> Option { + Self::ALL.into_iter().find(|p| p.code() == code) + } + + /// An invalid or obsolete saved choice follows the system language. + pub fn restore(saved: Option<&str>) -> Self { + saved.and_then(Self::parse).unwrap_or_default() + } + + pub fn resolve(self, locales: &[String]) -> &'static str { + match self { + Self::English => "en", + Self::Ukrainian => "uk", + Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"), + } + } +} + +pub struct Language { + pub code: &'static str, + pub name: &'static str, + pub source: &'static str, + catalog: OnceLock>, +} + +pub static LANGUAGES: [Language; 2] = [ + Language { code: "en", name: "English", source: "", catalog: OnceLock::new() }, + Language { code: "uk", name: "Українська", source: include_str!("uk.tsv"), catalog: OnceLock::new() }, +]; + +/// BCP 47 and POSIX locale tags, including `uk-UA` and `uk_UA.UTF-8`. +pub fn locale_language(tag: &str) -> Option<&'static str> { + let primary = tag.trim().split(['-', '_', '.', '@']).next()?; + LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(primary)).map(|l| l.code) +} + +thread_local! { + // Scoped to a single app's render. Headless calls and parallel tests default to English. + static CURRENT: Cell<&'static str> = const { Cell::new("en") }; +} + +pub fn with_language(language: &'static str, draw: impl FnOnce() -> R) -> R { + struct Restore(&'static str); + impl Drop for Restore { + fn drop(&mut self) { + CURRENT.set(self.0); + } + } + let _restore = Restore(CURRENT.replace(language)); + draw() +} + +type Catalog<'a> = HashMap<(&'a str, &'a str), &'a str>; + +/// Malformed rows are ignored at runtime and diagnosed by the catalog tests. +fn parse(source: &str) -> (Catalog<'_>, Vec) { + let mut entries = HashMap::new(); + let mut errors = Vec::new(); + for (i, line) in source.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let columns: Vec<_> = line.split('\t').collect(); + match columns.as_slice() { + [context, key, value] if !key.is_empty() && !value.is_empty() => { + if entries.insert((*context, *key), *value).is_some() { + errors.push(format!("line {}: duplicate key {key}", i + 1)); + } + } + _ => errors.push(format!("line {}: expected context, source, translation", i + 1)), + } + } + (entries, errors) +} + +fn catalog(language: &str) -> Option<&'static Catalog<'static>> { + LANGUAGES.iter().find(|l| l.code == language && !l.source.is_empty()).map(|l| l.catalog.get_or_init(|| parse(l.source).0)) +} + +pub fn tr<'a>(language: &str, source: &'a str) -> &'a str { + catalog(language).and_then(|c| c.get(&("", source))).copied().unwrap_or(source) +} + +pub fn t(source: &str) -> &str { + CURRENT.with(|l| tr(l.get(), source)) +} + +/// Named placeholders allow translations to reorder values. Inserted values are never looked up +/// or substituted again, so a user's name containing `{n}` is preserved verbatim. +pub fn format(source: &str, values: &[(&str, String)]) -> String { + let text = t(source); + let mut result = String::with_capacity(text.len()); + let mut rest = text; + while let Some((before, tail)) = rest.split_once('{') { + result.push_str(before); + let Some((key, after)) = tail.split_once('}') else { + result.push('{'); + rest = tail; + break; + }; + if let Some((_, value)) = values.iter().find(|(name, _)| *name == key) { + result.push_str(value); + } else { + result.push('{'); + result.push_str(key); + result.push('}'); + } + rest = after; + } + result.push_str(rest); + result +} + +/// Ukrainian integer plurals: one (1, 21), few (2–4, 22–24), many (0, 5–20, 25…). +pub fn plural_uk(n: u64) -> usize { + match (n % 10, n % 100) { + (1, 11) | (2..=4, 12..=14) => 2, + (1, _) => 0, + (2..=4, _) => 1, + _ => 2, + } +} + +pub fn count(n: u64, one: &str, other: &str) -> String { + let language = CURRENT.get(); + let source = catalog(language).and_then(|c| c.get(&("@plural", one))).copied(); + let form = source.and_then(|s| s.split('|').nth(plural_uk(n))).unwrap_or(if n == 1 { one } else { other }); + format(form, &[("n", n.to_string())]) +} + +#[macro_export] +macro_rules! tl { + ($source:literal) => { + $crate::i18n::t($source) + }; +} + +#[macro_export] +macro_rules! tf { + ($source:literal $(, $name:ident = $value:expr)* $(,)?) => { + $crate::i18n::format($source, &[$((stringify!($name), ($value).to_string())),*]) + }; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 00000000..32a855ab --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,290 @@ +use std::collections::BTreeSet; + +use super::*; +use crate::{CadApp, Services}; +use cadcraft_engine::Session; +use serde_json::json; + +fn placeholders(text: &str) -> BTreeSet<&str> { + text.split('{').skip(1).filter_map(|s| s.split_once('}').map(|(key, _)| key)).collect() +} + +#[test] +fn catalog_is_valid_and_preserves_placeholders_and_ellipses() { + for language in &LANGUAGES { + let (catalog, errors) = parse(language.source); + assert!(errors.is_empty(), "{}: {errors:?}", language.code); + for ((context, key), value) in catalog { + assert!(["", "@plural"].contains(&context), "unknown context: {context}"); + let forms: Vec<_> = if context == "@plural" { value.split('|').collect() } else { vec![value] }; + if context == "@plural" { + assert_eq!(forms.len(), 3, "{key}"); + } + for form in forms { + assert_eq!(placeholders(key), placeholders(form), "{key}"); + for suffix in ["...", "…"] { + assert_eq!(key.ends_with(suffix), form.ends_with(suffix), "{key}"); + } + } + } + } + let (parsed, errors) = parse("broken\n\tOpen\tВідкрити\n\tOpen\tВідкрити\n\tEmpty\t\n"); + assert_eq!(parsed.len(), 1); + assert_eq!(errors.len(), 3); + assert_eq!(tr("uk", "a future untranslated label"), "a future untranslated label"); + assert_eq!(tr("unknown", "Open"), "Open"); +} + +#[test] +fn ukrainian_covers_commands_menus_tools_and_snap_labels() { + let catalog = catalog("uk").unwrap(); + let mut sources = BTreeSet::new(); + sources.extend(crate::menus::MENUS.iter().copied()); + for c in cadcraft_engine::command_specs() { + sources.insert(c.label); + sources.extend(c.menu.iter().copied()); + } + for (_, label, path, _) in crate::menus::UI_COMMANDS { + sources.insert(*label); + sources.extend(path.iter().copied()); + } + for groups in [crate::palettes::DRAFTING, crate::palettes::MODELING] { + for (name, large, small) in groups { + sources.insert(*name); + sources.extend(large.iter().chain(*small).map(|(_, _, label)| *label)); + } + } + sources.extend(cadcraft_engine::snap::mode::ALL.iter().map(|(_, name)| *name)); + let missing: Vec<_> = sources.into_iter().filter(|s| !s.is_empty() && !catalog.contains_key(&("", *s))).collect(); + assert!(missing.is_empty(), "missing translations: {missing:?}"); +} + +#[test] +fn every_marked_ui_literal_has_a_translation() { + let catalog = catalog("uk").unwrap(); + let sources = [ + include_str!("../canvas.rs"), + include_str!("../chrome.rs"), + include_str!("../cmdline.rs"), + include_str!("../credits.rs"), + include_str!("../dialogs.rs"), + include_str!("../layers.rs"), + include_str!("../palettes.rs"), + include_str!("../parametric.rs"), + include_str!("../quick.rs"), + ]; + let mut missing = BTreeSet::new(); + for source in sources { + for marker in ["crate::tl!(", "crate::tf!("] { + for rest in source.split(marker).skip(1) { + // These macros require a quoted literal. Use JSON's string decoding for escapes. + let rest = rest.trim_start(); + let mut escape = false; + let end = rest + .char_indices() + .skip(1) + .find_map(|(i, c)| { + if escape { + escape = false; + None + } else if c == '\\' { + escape = true; + None + } else if c == '"' { + Some(i + 1) + } else { + None + } + }) + .unwrap(); + let key: String = serde_json::from_str(&rest[..end]).unwrap(); + // Coordinate axes, glyph buttons and product names are not translated. + if !["X", "Y", "B", "CADCraft"].contains(&key.as_str()) && !catalog.contains_key(&("", key.as_str())) { + missing.insert(key); + } + } + } + } + assert!(missing.is_empty(), "missing UI translations: {missing:?}"); +} + +#[test] +fn locale_and_saved_preference_resolution() { + for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", " uk-UA ", "uk_UA@euro"] { + assert_eq!(locale_language(tag), Some("uk"), "{tag}"); + } + for tag in ["ua", "ukulele", "ru-RU", "", "garbage", "💙"] { + assert_eq!(locale_language(tag), None, "{tag}"); + } + let locales = vec!["pl-PL".into(), "uk-UA".into(), "en-US".into()]; + assert_eq!(Preference::Auto.resolve(&locales), "uk"); + assert_eq!(Preference::English.resolve(&locales), "en"); + assert_eq!(Preference::Auto.resolve(&["en-US".into(), "uk-UA".into()]), "en"); + assert_eq!(Preference::Auto.resolve(&[]), "en"); + assert_eq!(Preference::restore(None), Preference::Auto); + assert_eq!(Preference::restore(Some("obsolete")), Preference::Auto); + for preference in Preference::ALL { + assert_eq!(Preference::restore(Some(preference.code())), preference); + let saved = serde_json::to_string(&preference).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap(), preference); + } + assert!(serde_json::from_str::("\"unsupported\"").is_err()); + assert_eq!(serde_json::from_value::(json!({})).unwrap().interface_language, Preference::Auto); + // Saved with the host's other preferences; a bad saved value keeps the current choice. + let mut a = CadApp::new(Session::new(), Services::default()); + a.ui.interface_language = Preference::Ukrainian; + let mut b = CadApp::new(Session::new(), Services::default()); + b.load_prefs(&a.prefs_json()); + assert_eq!(b.ui.interface_language, Preference::Ukrainian); + b.load_prefs(r#"{"interfaceLanguage":"klingon"}"#); + assert_eq!(b.ui.interface_language, Preference::Ukrainian); +} + +#[test] +fn plural_forms_and_templates_preserve_inserted_user_data() { + with_language("uk", || { + for (n, form) in [ + (0, 2), + (1, 0), + (2, 1), + (4, 1), + (5, 2), + (11, 2), + (12, 2), + (14, 2), + (21, 0), + (22, 1), + (25, 2), + (101, 0), + (111, 2), + (114, 2), + (u64::MAX, 2), + ] { + assert_eq!(plural_uk(n), form, "{n}"); + } + assert_eq!(count(1, "{n} contributor", "{n} contributors"), "1 учасник"); + assert_eq!(count(22, "{n} contributor", "{n} contributors"), "22 учасники"); + assert_eq!(count(11, "{n} contributor", "{n} contributors"), "11 учасників"); + assert_eq!(format("Current: {color}", &[("color", "{n} Open".into())]), "Поточний: {n} Open"); + assert_eq!(format("{b}/{a}", &[("a", "А".into()), ("b", "Б".into())]), "Б/А"); + }); + assert_eq!(t("Layers"), "Layers"); + with_language("uk", || { + assert_eq!(t("Layers"), "Шари"); + with_language("en", || assert_eq!(t("Layers"), "Layers")); + assert_eq!(t("Layers"), "Шари"); + }); + assert_eq!(count(2, "{n} contributor", "{n} contributors"), "2 contributors"); +} + +fn painted_text(shapes: &[egui::epaint::ClippedShape]) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(s) => { + text.push_str(s.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, text); + } + } + _ => {} + } + } + let mut text = String::new(); + for shape in shapes { + collect(&shape.shape, &mut text); + } + text +} + +fn render(app: &mut CadApp, ctx: &egui::Context) -> String { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + // Windows fade in; a second pass makes their text visible without a time dependency. + let mut text = String::new(); + for _ in 0..2 { + let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui)); + text = painted_text(&output.shapes); + output.textures_delta.clear(); + } + text +} + +#[test] +fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.in_window_menu = true; + app.session.state_mut().unwrap().title = "Open".into(); + let ctx = egui::Context::default(); + app.logic(&ctx); + let before = render(&mut app, &ctx); + assert!(before.contains("Properties")); + let commands = crate::control::all_commands(&app); + let (menu_request, _) = crate::ControlRequest::new("ui.menu.list", json!({})); + let crate::control::Outcome::Done(menu_before) = crate::control::handle(&mut app, &ctx, &menu_request) else { panic!("expected menu reply") }; + app.start("line"); + let prompt = crate::control::cmdline_state(&app); + app.run("ui.language", json!({"lang":"uk"})).unwrap(); + let after = render(&mut app, &ctx); + assert!(after.contains("Властивості"), "{after}"); + assert!(after.contains("Файл"), "{after}"); + assert!(after.contains("Укажіть першу точку"), "{after}"); + assert!(after.contains("Open"), "the drawing title must remain unchanged"); + assert_eq!(crate::control::cmdline_state(&app), prompt); + assert_eq!(crate::control::all_commands(&app), commands); + let crate::control::Outcome::Done(menu_after) = crate::control::handle(&mut app, &ctx, &menu_request) else { panic!("expected menu reply") }; + assert_eq!(menu_before, menu_after); + assert_eq!(app.session.state().unwrap().title, "Open"); + assert_eq!(t("Properties"), "Properties", "language must not leak out of the render scope"); + assert!(app.run("ui.language", json!({"lang":"bad"})).is_err()); + assert_eq!(app.language(), "uk"); + app.run("ui.resetpalettes", json!({})).unwrap(); + assert_eq!(app.language(), "uk"); + app.run("ui.language", json!({"lang":"en"})).unwrap(); + assert!(render(&mut app, &ctx).contains("Properties")); + // The menu tree used by the control channel keeps its original labels and IDs. + assert_eq!(crate::menus::tree(&app).first().unwrap().0, "File"); +} + +#[test] +fn dialogs_render_in_ukrainian_and_keep_their_source_ids() { + for (dialog, heading) in [ + ("dsettings", "Налаштування креслення"), + ("about", "Про CADCraft"), + ("commands", "Довідник команд"), + ("blocks", "Блоки"), + ("layers", "Диспетчер властивостей шарів"), + ("qselect", "Швидкий вибір"), + ("parameters", "Диспетчер параметрів"), + ("language", "Мова інтерфейсу"), + ] { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.interface_language = Preference::Ukrainian; + app.ui.dialog = Some(dialog.into()); + let ctx = egui::Context::default(); + app.logic(&ctx); + let text = render(&mut app, &ctx); + assert!(text.contains(heading), "{dialog}: {text}"); + assert_eq!(app.ui.dialog.as_deref(), Some(dialog)); + } +} + +#[test] +fn default_fonts_cover_every_ukrainian_letter_in_the_catalog() { + let ctx = egui::Context::default(); + // Use only egui's bundled fonts, exactly as the web build does (no system-font loader). + let mut output = ctx.run_ui(Default::default(), |_| {}); + output.textures_delta.clear(); + let letters: BTreeSet<_> = include_str!("uk.tsv").chars().filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect(); + ctx.fonts_mut(|view| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + // In egui 0.36, has_glyph can report a false negative for glyphs in the same + // face as the replacement character. Check the family's actual character maps. + let mut font = view.fonts.font(&family); + for c in &letters { + assert!(font.characters().contains_key(c), "missing {c} in {family:?}"); + } + } + }); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv new file mode 100644 index 00000000..c054517f --- /dev/null +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -0,0 +1,942 @@ +# CADCraft Ukrainian interface catalog — original translations by @dmatviichuk. +# License: MIT OR Apache-2.0 (the repository LICENSE-MIT and LICENSE-APACHE). +# Written from CADCraft's English strings; no proprietary translations were copied. +# UTF-8: contextEnglish sourceUkrainian translation. +# Empty context = ordinary label; @plural = one|few|many forms with {n}. +# Keep command IDs, keywords, technology/product names and user data unchanged. + Cancel Скасувати + OK Гаразд + or [ або [ + % of all commits % усіх комітів + * Wildcard Match * За шаблоном + *VARIES* *РІЗНІ* + + | Top | 2D Wireframe + | Зверху | 2D-каркас + +Bin +двійк. + +LOC +рядків + 1 Viewport 1 видовий екран + 2 Points 2 точки + 2 Viewports 2 видові екрани + 2D Solid 2D-заливка + 3 Points 3 точки + 3 Viewports 3 видові екрани + 3D Face 3D-грань + 3D Operations 3D-операції + 3D Polyline 3D-полілінія + 4 Viewports 4 видові екрани + < Less than < Менше ніж + <> Not Equal <> Не дорівнює + = Equals = Дорівнює + > Greater than > Більше ніж + About Про програму + About CADCraft Про CADCraft + Absolute Абсолютні + Add Leader Додати виноску + Align Вирівняти + Align Text Вирівняти текст + Aligned Паралельний + All Усі + All Used Layers Усі використані шари + All files Усі файли + Angle Кут + Angular Кутовий + Annotation Visibility Видимість анотацій + Annotation scale Масштаб анотацій + Apparent Intersection Уявний перетин + Append to current selection set Додати до поточного набору вибраних об'єктів + Apply to: Застосувати до: + Apply true color Застосувати колір RGB + Arc Дуга + Arc Length Довжина дуги + Area Площа + Array Масив + Attach Приєднати + Attribute Атрибут + Attribute Definition Визначення атрибута + Auto (system language) Авто (мова системи) + Auto Constrain Автоматичні обмеження + AutoConstrain Автоматичні обмеження + Axis, End Вісь, кінець + Base База + Baseline Базовий + Batch Publish Пакетне публікування + Binary assets added Додані двійкові ресурси + Binary assets removed Видалені двійкові ресурси + Blend Плавне з'єднання + Block Блок + Block Attribute Manager... Диспетчер атрибутів блоків... + Block Editor Редактор блоків + Block Reference Посилання на блок + Block... Блок... + Blocks Блоки + Blue Синій + Bold Жирний + Boolean Булеві операції + Boundary Межа + Boundary... Межа... + Box Паралелепіпед + Break Розірвати + Break At Point Розірвати в точці + Bring to Front На передній план + ByBlock За блоком + ByColor За кольором + ByLayer За шаром + CADCraft Help Довідка CADCraft + Cancel Скасувати + Cartesian (x, y) Декартові (x, y) + Center Центр + Center Line Осьова лінія + Center Mark Позначка центра + Center X Центр X + Center Y Центр Y + Center Z Центр Z + Center, Diameter Центр, діаметр + Center, Radius Центр, радіус + Center, Start, Angle Центр, початок, кут + Center, Start, End Центр, початок, кінець + Center, Start, Length Центр, початок, довжина + Chamfer Фаска + Change Space Змінити простір + Change to Current Layer Перенести на поточний шар + Circle Коло + Circumference Довжина кола + Clear Очистити + Close Закрити + Close All Закрити всі + Closed Замкнений + Coincident Збіг + Collapse Tool Sets Згорнути набори інструментів + Collinear Колінеарність + Color Колір + Color 8 Колір 8 + Color 9 Колір 9 + Color Index Індекс кольору + Color... Колір... + Command History Історія команд + Command Line Командний рядок + Command Line History Lines Рядки історії командного рядка + Command Reference Довідник команд + Commits Коміти + Company Компанія + Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Автоматизоване проєктування та креслення: CAD-програма з відкритим кодом, незалежно розроблена мовою Rust. + Concentric Концентричність + Cone Конус + Constraint Bars Панелі обмежень + Constraint Settings Налаштування обмежень + Construction Line Допоміжна пряма + Contents Вміст + Continue Продовжити + Contributors Учасники + Control Vertices Керувальні вершини + Copy Копіювати + Copy Layout Копіювати аркуш + Copy Nested Objects Копіювати вкладені об'єкти + Copy with Base Point Копіювати з базовою точкою + Count Кількість + Create Block Створити блок + Creates a user parameter Створює параметр користувача + Ctrl+Enter to finish Ctrl+Enter для завершення + Current layer Поточний шар + Current selection Поточний вибір + Current: {color} Поточний: {color} + Cut Вирізати + Cyan Блакитний + Cylinder Циліндр + Dark Темна + Default Типово + Define Attributes Визначити атрибути + Define Attributes... Визначити атрибути... + Defined width Задана ширина + Degree Градус + Delete Видалити + Delete Column Видалити стовпець + Delete Constraints Видалити обмеження + Delete Dimension Style Видалити розмірний стиль + Delete Duplicate Objects Видалити дублікати об'єктів + Delete Layer Видалити шар + Delete Layer State Видалити стан шарів + Delete Layout Видалити аркуш + Delete Row Видалити рядок + Delete Text Style Видалити текстовий стиль + Delta X Різниця X + Delta Y Різниця Y + Description Опис + Diameter Діаметр + Dim style Розмірний стиль + Dimension Розміри + Dimension Space Відстань між розмірами + Dimension Style Override Перевизначення розмірного стилю + Dimension Style... Розмірний стиль... + Dimension Text Edit Редагувати розмірний текст + Dimension style Розмірний стиль + Dimensional Constraint Розмірне обмеження + Dimensional Constraint Parameters Параметри розмірних обмежень + Dimensional Constraints Розмірні обмеження + Disassociate Dimensions Скасувати асоціативність розмірів + Display name Відображуване ім'я + Distance Відстань + Divide Поділити + Do not plot (click: plot) Не друкувати (натисніть для друку) + Donut Кільце + Double-click to edit the description Двічі клацніть, щоб редагувати опис + Down Униз + Drafting Креслення + Drafting Settings Налаштування креслення + Drafting Settings... Налаштування креслення... + Draw Креслення + Draw Order Порядок відображення + Drawing (DXF) Креслення (DXF) + Drawing Limits Межі креслення + Drawings (DWG, DXF) Креслення (DWG, DXF) + Dynamic Dimensions Динамічні розміри + Dynamic Input Динамічне введення + Dynamic Input (F12) Динамічне введення (F12) + E Сх + Edit Редагування + Edit Text... Редагувати текст... + Elevation Відмітка + Ellipse Еліпс + Elliptical Arc Еліптична дуга + Enable Dynamic Input (F12) Увімкнути динамічне введення (F12) + End Width Кінцева ширина + End X Кінець X + End Y Кінець Y + End angle Кінцевий кут + Endpoint Кінцева точка + Enter Top, Zero or Bottom. Введіть Top, Zero або Bottom. + Enter a fit data option Введіть параметр апроксимації + Enter a pattern name Введіть назву візерунка + Enter a vertex editing option Введіть параметр редагування вершини + Enter an option Введіть параметр + Enter angle of xline Введіть кут допоміжної прямої + Enter angle of xline (0) or Введіть кут допоміжної прямої (0) або + Enter area of rectangle in current units Введіть площу прямокутника в поточних одиницях + Enter attribute prompt Введіть підказку атрибута + Enter attribute tag name Введіть ім'я тегу атрибута + Enter attribute value Введіть значення атрибута + Enter block name Введіть назву блоку + Enter constraint type Введіть тип обмеження + Enter continuity Введіть тип неперервності + Enter default attribute value Введіть типове значення атрибута + Enter degree of spline Введіть степінь сплайна + Enter delta angle Введіть зміну кута + Enter delta length or Введіть зміну довжини або + Enter dimension text Введіть розмірний текст + Enter dimension text (<> = measurement) Введіть розмірний текст (<> = виміряне значення) + Enter dimension value or [name=expression] Введіть розмірне значення або [name=expression] + Enter dimension variable name to override Введіть назву розмірної змінної для перевизначення + Enter justification type Введіть тип вирівнювання + Enter leader text Введіть текст виноски + Enter mline scale Введіть масштаб мультилінії + Enter mode Введіть режим + Enter name of block to insert Введіть назву блоку для вставлення + Enter new text Введіть новий текст + Enter next point Введіть наступну точку + Enter number of columns Введіть кількість стовпців + Enter number of data rows Введіть кількість рядків даних + Enter number of items Введіть кількість елементів + Enter number of items along path Введіть кількість елементів уздовж траєкторії + Enter number of sides Введіть кількість сторін + Enter number of turns Введіть кількість витків + Enter percentage length Введіть довжину у відсотках + Enter rectangle length Введіть довжину прямокутника + Enter rectangle width Введіть ширину прямокутника + Enter scale factor Введіть коефіцієнт масштабу + Enter text Введіть текст + Enter text (use \P for new paragraphs) Введіть текст (\P починає новий абзац) + Enter the number of segments Введіть кількість сегментів + Enter twist direction of helix Введіть напрямок закручування спіралі + Entire drawing Усе креслення + Equal Рівність + Erase Стерти + Exclude from new selection set Виключити з нового набору вибраних об'єктів + Explode Розчленувати + Export Експортувати + Export to PDF... Експортувати в PDF... + Expression Вираз + Extend Подовжити + Extension Продовження + Extents За межами об'єктів + Extrude Видавити + File Файл + File Tab Вкладка файлу + Fillet Спряження + Filter Фільтр + Filters Фільтри + Find and Replace Знайти й замінити + Find... Знайти... + First Перший + First commit Перший коміт + Fit Points Точки апроксимації + Fix Фіксація + Flatten Objects Спроєктувати об'єкти на площину + Format Формат + Freehand Від руки + Freeze Заморозити + Freeze Layer Заморозити шар + Freeze in new viewports Заморозити в нових видових екранах + Freeze/Thaw Заморозити/розморозити + Freeze/Thaw in all viewports Заморозити/розморозити в усіх видових екранах + Freeze/Thaw in the current viewport Заморозити/розморозити в поточному видовому екрані + General Загальні + Geometric Center Геометричний центр + Geometric Constraint Геометричне обмеження + Geometric Constraints Геометричні обмеження + Geometry Геометрія + Get Variable Отримати змінну + Get View Отримати вид + Global width Загальна ширина + Grab bag Список + Gradient Градієнт + Gradient... Градієнт... + Green Зелений + Grid (F7) Сітка (F7) + Grid Display Відображення сітки + Grid On (F7) Увімкнути сітку (F7) + Grid spacing Крок сітки + Grip Mirror Дзеркальне відображення ручками + Grip Rotate Поворот ручками + Grip Scale Масштабування ручками + Grip Stretch Розтягування ручками + Hatch Штрихування + Hatch Edit Редагувати штрихування + Hatch... Штрихування... + Height Висота + Height: {height} Висота: {height} + Helix Спіраль + Help Довідка + Hide All Приховати всі + Hide All Constraint Bars Приховати всі панелі обмежень + Hide All Dynamic Dimensions Приховати всі динамічні розміри + Hide Layer List Приховати список шарів + Hide Palettes Приховати палітри + Home Початковий вид + Horizontal Горизонтальність + HorizontalDistance Горизонтальна відстань + How to apply: Спосіб застосування: + ID Point Координати точки + Import Імпортувати + In Наблизити + In-window Menu Bar Рядок меню у вікні + Include in new selection set Включити до нового набору вибраних об'єктів + Increment angle Крок кута + Index Color Індексований колір + Index color: {index} Індексований колір: {index} + Inquiry Вимірювання + Insert Вставлення + Insert Block Вставити блок + Insert Column Вставити стовпець + Insert Row Вставити рядок + Insert this block Вставити цей блок + Insertion Точка вставлення + Insertion X Вставлення X + Insertion Y Вставлення Y + Insertion Z Вставлення Z + Inspect Constraints Переглянути обмеження + Inspect Drawing Переглянути креслення + Interface Language Мова інтерфейсу + Interface Theme Тема інтерфейсу + Intersect Перетин + Intersection Перетин + Isolate Ізолювати + Isolate Layer Ізолювати шар + Isometric Drafting Ізометричне креслення + Join Об'єднати + Join the ArtCraft Discord… Приєднатися до Discord ArtCraft… + Join us on Discord Приєднуйтеся до нас у Discord + Language Мова + Last Останній + Last commit Останній коміт + Layer Шар + Layer Match Узгодити шар + Layer Off Вимкнути шар + Layer Properties Властивості шару + Layer Properties Manager Диспетчер властивостей шарів + Layer Properties Manager — current viewport Диспетчер властивостей шарів — поточний видовий екран + Layer State Стан шарів + Layer States Manager... Диспетчер станів шарів... + Layer Tools Інструменти шарів + Layer contains objects (double-click to make current) Шар містить об'єкти (двічі клацніть, щоб зробити поточним) + Layer is empty (double-click to make current) Шар порожній (двічі клацніть, щоб зробити поточним) + Layers Шари + Layers displayed: {shown} of {total}. Current layer: {layer} Показано шарів: {shown} із {total}. Поточний шар: {layer} + Layout Аркуш + Layout list Список аркушів + Leader Виноска + Left Ліворуч + Length Довжина + Length X Довжина X + Length Y Довжина Y + Lengthen Змінити довжину + Light Світла + Line Відрізок + Line delta Зміна кількості рядків + Linear Лінійний + Lines +/− Рядки +/− + Lines added Додані рядки + Lines deleted Видалені рядки + Linetype Тип лінії + Linetype Scale Масштаб типу лінії + Linetype scale Масштаб типу лінії + Ltscale Масштаб типу лінії + Linetype... Тип лінії... + Lineweight Товщина лінії + Lineweight... Товщина лінії... + List Список + List Blocks Список блоків + List Dimension Styles Список розмірних стилів + List Layer States Список станів шарів + List Layouts Список аркушів + List System Variables Список системних змінних + List Text Styles Список текстових стилів + Load all Завантажити всі + Load... Завантажити... + Loaded linetypes Завантажені типи ліній + Lock Заблокувати + Lock Layer Заблокувати шар + Lock/Unlock Заблокувати/розблокувати + Loft Тіло за перерізами + MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Не пов'язано з Autodesk, Inc. + MODEL МОДЕЛЬ + MText Багаторядковий текст + Magenta Пурпуровий + Major line every Основна лінія через кожні + Make Current Зробити поточним + Make Object's Layer Current Зробити шар об'єкта поточним + Make... Створити... + Match Layer Узгодити шар + Match Properties Копіювати властивості + Measure Розмітити + Measure Distance Виміряти відстань + Measure Geometry Виміряти геометрію + Merge Cells Об'єднати комірки + Merged PRs Об'єднані PR + Midpoint Середина + Mirror Дзеркальне відображення + Model Модель + Model Space (in viewport) Простір моделі (у видовому екрані) + Modeling Моделювання + Models Моделі + Modify Модифікація + Move Перемістити + Multileader Мультивиноска + Multileader Style... Стиль мультивиносок... + Multileader style Стиль мультивиносок + Multiline Мультилінія + Multiline Text Багаторядковий текст + Multiline Text... Багаторядковий текст... + Multiple Різні типи + Multiple Point Кілька точок + My Мої + My properties: not available yet Мої властивості: поки недоступно + N Пн + Name Назва + Name (A–Z) Назва (А–Я) + Nearest Найближча точка + New Створити + New (metric) Створити (метричне) + New Drawing Нове креслення + New Drawing (⌘N) Нове креслення (⌘N) + New Drawing... Нове креслення... + New Layer Новий шар + New Layout Новий аркуш + New VP Freeze Заморозити в нових ВЕ + New Viewports... Нові видові екрани... + New layout Новий аркуш + New state name Назва нового стану + No Ні + No blocks defined in this drawing. У цьому кресленні немає визначених блоків. + No contributor data was built into this copy. Ця збірка не містить даних про учасників. + No model credits were built into this copy. Ця збірка не містить даних про моделі. + No saved layer states Немає збережених станів шарів + No selection Нічого не вибрано + Node Вузол + None Немає + Not available Недоступно + Not available yet Поки недоступно + OK Гаразд + Object Об'єкт + Object Snap Об'єктна прив'язка + Object Snap (F3) Об'єктна прив'язка (F3) + Object Snap On (F3) Увімкнути об'єктну прив'язку (F3) + Object Snap Tracking Відстеження об'єктної прив'язки + Object Snap Tracking (F11) Відстеження об'єктної прив'язки (F11) + Object type: Тип об'єкта: + Off Вимкнено + Offset Зміщення + On Увімкнено + On/Off Увімкнути/вимкнути + One of the Crafting Apps by the ArtCraft team and community. Одна з програм Crafting Apps від команди та спільноти ArtCraft. + Open (⌘O) Відкрити (⌘O) + Open Sample Drawing Відкрити зразок креслення + Open sample drawing Відкрити зразок креслення + Open... Відкрити... + Operator: Оператор: + Orbit Орбіта + Ordinate Ординатний + Ortho (F8) Орто (F8) + Ortho Mode Режим Орто + Ortho Mode (F8) Режим Орто (F8) + Out Віддалити + Override Перевизначити + PAPER АРКУШ + Percent Відсоток + Polar (distance < angle) Полярні (відстань < кут) + PRs PR + Page Setup Manager Диспетчер параметрів аркушів + Page Setup Manager... Диспетчер параметрів аркушів... + Pan Панорамування + Pan Down Панорамувати вниз + Pan Left Панорамувати ліворуч + Pan Right Панорамувати праворуч + Pan Up Панорамувати вгору + Paper Space Простір аркуша + Parallel Паралельність + Parameters Manager Диспетчер параметрів + Parametric Параметризація + Paste Вставити + Paste to Original Coordinates Вставити у вихідні координати + Path Array Масив уздовж траєкторії + Pattern Візерунок + Perpendicular Перпендикулярність + Plot Друк креслення + Plot (click: do not plot) Друкувати (натисніть, щоб вимкнути друк) + Plot Preview Попередній перегляд друку + Plot style Стиль друку + Plot style attached to Стиль друку застосовано до + Plot style table Таблиця стилів друку + Plot table type Тип таблиці друку + Plus/minus Плюс/мінус + Point Точка + Point Style... Стиль точок... + Polar Array Круговий масив + Polar Tracking Полярне відстеження + Polar Tracking (F10) Полярне відстеження (F10) + Polar Tracking On (F10) Увімкнути полярне відстеження (F10) + Polar: {distance} < {angle}° Полярне: {distance} < {angle}° + Polygon Правильний багатокутник + Polygonal Багатокутний + Polyline Полілінія + Position X Позиція X + Position Y Позиція Y + Position Z Позиція Z + Previous Попередній + Previous Layer Попередній шар + Primitives Примітиви + Print Друк + Print... Друк... + Properties Властивості + Properties Inspector Інспектор властивостей + Properties: Властивості: + Proxy Проксі-об'єкт + Purge Очистити невикористане + Pyramid Піраміда + Quadrant Квадрант + Query Entities Запит об'єктів + Quick Швидкий + Quick Dimension Швидкий розмір + Quick Properties Швидкі властивості + Quick Select Швидкий вибір + Quick Select Properties Властивості швидкого вибору + Quick Select... Швидкий вибір... + QuickCalc Калькулятор + Quit CADCraft Вийти з CADCraft + Radius Радіус + Raster Image Растрове зображення + Ray Промінь + Read-only Лише для читання + Real name Справжнє ім'я + Realtime У реальному часі + Reassociate Dimensions Відновити асоціативність розмірів + Recent Нещодавні + Rectangle Прямокутник + Rectangular Прямокутний + Rectangular Array Прямокутний масив + Red Червоний + Redo Повторити + Redo (⇧⌘Z) Повторити (⇧⌘Z) + Redraw Перемалювати + Regen Регенерувати + Regen All Регенерувати все + Region Область + Region/Mass Properties Властивості області/маси + Relative Відносні + Remove Leader Видалити виноску + Remove the viewport override Видалити перевизначення видового екрана + Rename Dimension Style Перейменувати розмірний стиль + Rename Layer Перейменувати шар + Rename Layer State Перейменувати стан шарів + Rename Layout Перейменувати аркуш + Rename Text Style Перейменувати текстовий стиль + Rename... Перейменувати... + Requires a point (x,y, @dx,dy or @d Укажіть другу точку або <використати першу точку як зміщення> + Specify second source point Укажіть другу вихідну точку + Specify side to offset Укажіть бік зміщення + Specify start angle Укажіть початковий кут + Specify start point Укажіть початкову точку + Specify start point of arc Укажіть початкову точку дуги + Specify start point of polyline Укажіть початкову точку полілінії + Specify start point of text Укажіть початкову точку тексту + Specify starting width Укажіть початкову ширину + Specify starting width for next segment Укажіть початкову ширину наступного сегмента + Specify tangent direction for the start point of arc Укажіть напрямок дотичної в початковій точці дуги + Specify the elevation for rectangles Укажіть відмітку прямокутників + Specify thickness for rectangles Укажіть висоту видавлювання прямокутників + Specify third point on circle Укажіть третю точку на колі + Specify third point on circle: _tan to Укажіть третю точку на колі: _tan до + Specify third source point or Укажіть третю вихідну точку або <продовжити> + Specify through point Укажіть точку, через яку проходить пряма + Specify top diameter Укажіть діаметр вершини + Specify top radius or Укажіть радіус вершини або + Specify total angle Укажіть загальний кут + Specify total length or Укажіть загальну довжину або + Specify width for rectangles Укажіть ширину прямокутників + Sphere Сфера + Spline Сплайн + Stack (type 1/2 then select) Дріб (введіть 1/2, потім виділіть) + Standard Стандартний + Start Початок + Start Width Початкова ширина + Start X Початок X + Start Y Початок Y + Start angle Початковий кут + Start, Center, Angle Початок, центр, кут + Start, Center, End Початок, центр, кінець + Start, Center, Length Початок, центр, довжина + Start, End, Angle Початок, кінець, кут + Start, End, Direction Початок, кінець, напрямок + Start, End, Radius Початок, кінець, радіус + Status Стан + Status Bar Рядок стану + Stretch Розтягнути + Style: Standard Стиль: Standard + Subtract Відняти + Sweep Тіло вздовж траєкторії + Switch Drawing Перемкнути креслення + Switch Layout Перемкнути аркуш + Switch between model space in a viewport and paper space Перемкнути між простором моделі у видовому екрані та простором аркуша + Symmetric Симетрія + Toggle PICKADD (off: each pick replaces the selection) Перемкнути PICKADD (вимкнено: кожен вибір замінює набір вибраних об'єктів) + Toggle PICKADD (on: picks add to the selection) Перемкнути PICKADD (увімкнено: вибране додається до набору вибраних об'єктів) + TOP ЗВЕРХУ + Table Таблиця + Table Style... Стиль таблиць... + Table style Стиль таблиць + Table... Таблиця... + Tan, Tan, Radius Дотична, дотична, радіус + Tan, Tan, Tan Дотична, дотична, дотична + Tangent Дотичність + Text Текст + Text Editor Текстовий редактор + Text Height Висота тексту + Text Style... Текстовий стиль... + Text height Висота тексту + Text override Перевизначення тексту + Text style Текстовий стиль + Thaw All Layers Розморозити всі шари + There is nothing here yet. Тут поки що нічого немає. + Thickness Висота видавлювання + Time Час + To get started, create or open a drawing. Щоб почати, створіть або відкрийте креслення. + Tool Bar Панель інструментів + Tool Sets Набори інструментів + Tools Інструменти + Torus Тор + Trace Смуга + Transparency Прозорість + Trim Обрізати + True Color Колір RGB + Turn All Layers On Увімкнути всі шари + Turn Off Layer Вимкнути шар + Turn Quick Properties off (QPMODE) Вимкнути швидкі властивості (QPMODE) + Type Тип + Type a command Введіть команду + Type text; Enter starts a new paragraph Введіть текст; Enter починає новий абзац + UCS Icon Піктограма СКК + Undo Скасувати дію + Undo (⌘Z) Скасувати дію (⌘Z) + Union Об'єднання + Unisolate Layer Скасувати ізоляцію шарів + Units... Одиниці... + Unlock Layer Розблокувати шар + Unmerge Cells Розділити комірки + Unsaved Layer State Незбережений стан шарів + Up Угору + Update Оновити + Use System Setting Як у системі + User Користувач + User Parameters Параметри користувача + Username Ім'я користувача + VP Color Колір ВЕ + VP Freeze Заморожування ВЕ + Value Значення + Value: Значення: + Version Версія + Version {version} Версія {version} + Vertical Вертикальність + VerticalDistance Вертикальна відстань + View Вид + ViewCube Куб виду + Viewport Видовий екран + Viewport Layer Freeze Заморозити шар у видовому екрані + Viewport Properties Властивості видового екрана + Viewport {id} Видовий екран {id} + Viewports Видові екрани + W Зх + WCS ⌄ ССК ⌄ + White Білий + Width factor Коефіцієнт ширини + Window Вікно + Wipeout Маскування + Workspace Switching Перемикання робочого простору + Write Block Записати блок у файл + Xline Допоміжна пряма + Yellow Жовтий + Yes Так + Zoom Масштабування виду + Zoom All Показати все + Zoom Extents Показати межі об'єктів + Zoom In Наблизити + Zoom Object Показати об'єкт + Zoom Out Віддалити + Zoom Previous Попередній масштаб + Zoom Window Масштабувати рамкою + error помилка + {contributors} · {commits} {contributors} · {commits} + {kind} (constraint {id}) {kind} (обмеження {id}) + {kind} constraint {name} = {expression} (double-click to edit) Обмеження «{kind}» {name} = {expression} (двічі клацніть для редагування) + ƒx New User Parameter ƒx Новий параметр користувача + ΔLOC Δрядків + −Bin −двійк. + −LOC −рядків + ⌘↩ to finish ⌘↩ для завершення +@plural {n} commit {n} коміт|{n} коміти|{n} комітів +@plural {n} contributor {n} учасник|{n} учасники|{n} учасників + Drawing as bytes Креслення як байти + Services Служби + Hide CADCraft Приховати CADCraft + Hide Others Приховати інші diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1367c329..272197b4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -917,5 +917,5 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: ui.painter().rect_filled(rect, 3.0, t.control_hover.gamma_multiply(0.6)); } paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled()); - resp.on_hover_text(tooltip) + resp.on_hover_text(crate::i18n::t(tooltip)) } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f30e1b8a..93e40c06 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -49,7 +49,7 @@ pub fn color_button(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool, w p.rect_filled(rect, 2.0, t.control_hover.gamma_multiply(0.5)); } swatch(p, Rect::from_min_size(pos2(rect.left() + 3.0, rect.center().y - 6.0), vec2(12.0, 12.0)), current); - p.text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, current.name(), crate::theme::body(), t.text); + p.text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(¤t.name()), crate::theme::body(), t.text); let mut out = None; egui::Popup::from_toggle_button_response(&resp).id(id).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { out = color_palette(ui, id, current, by); @@ -68,7 +68,7 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) let mut out = None; ui.horizontal(|ui| { for (i, l) in ["Index Color", "True Color"].iter().enumerate() { - if ui.selectable_label(tab == i as u8, *l).clicked() { + if ui.selectable_label(tab == i as u8, crate::i18n::t(l)).clicked() { tab = i as u8; } } @@ -83,7 +83,7 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) if sel || resp.hovered() { ui.painter().rect_stroke(r, 0.0, Stroke::new(if sel { 2.0 } else { 1.0 }, Color32::WHITE), egui::StrokeKind::Inside); } - if resp.on_hover_text(format!("Index color: {i}")).clicked() { + if resp.on_hover_text(crate::tf!("Index color: {index}", index = i)).clicked() { *out = Some(Color::Index(i)); } }; @@ -113,15 +113,15 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) ui.add_space(6.0); if by { ui.horizontal(|ui| { - if ui.button("ByLayer").clicked() { + if ui.button(crate::tl!("ByLayer")).clicked() { out = Some(Color::ByLayer); } - if ui.button("ByBlock").clicked() { + if ui.button(crate::tl!("ByBlock")).clicked() { out = Some(Color::ByBlock); } }); } - ui.label(RichText::new(format!("Current: {}", current.name())).small().color(t.text_dim)); + ui.label(RichText::new(crate::tf!("Current: {color}", color = crate::i18n::t(¤t.name()))).small().color(t.text_dim)); } else { let rid = id.with("rgb"); let start = current.resolve(Color::Index(7), Color::Index(7)); @@ -138,7 +138,7 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) } }); ui.data_mut(|d| d.insert_temp(rid, c)); - if ui.button("Apply true color").clicked() { + if ui.button(crate::tl!("Apply true color")).clicked() { out = Some(Color::True(cadcraft_color::Rgb(c[0], c[1], c[2]))); ui.data_mut(|d| d.remove::<[u8; 3]>(rid)); } @@ -237,9 +237,13 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { Some(_) => "Layer Properties Manager — current viewport", None => "Layer Properties Manager", }; - egui::Window::new(title).id(egui::Id::new("lpm_window")).open(open).default_size(vec2(1180.0, 440.0)).min_width(640.0).resizable(true).show( - ctx, - |ui| { + egui::Window::new(crate::i18n::t(title)) + .id(egui::Id::new("lpm_window")) + .open(open) + .default_size(vec2(1180.0, 440.0)) + .min_width(640.0) + .resizable(true) + .show(ctx, |ui| { // ----- tool row ----- ui.horizontal(|ui| { if icons::button(ui, Icon::Plus, 24.0, "New Layer", false).clicked() { @@ -264,8 +268,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } ui.separator(); // Layer states. - ui.label("Layer State"); - let label = st.state_sel.clone().unwrap_or_else(|| "Unsaved Layer State".into()); + ui.label(crate::tl!("Layer State")); + let label = st.state_sel.clone().unwrap_or_else(|| crate::tl!("Unsaved Layer State").into()); egui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| { for s in &states { if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() { @@ -273,29 +277,29 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } if states.is_empty() { - ui.label(RichText::new("No saved layer states").color(t.text_dim)); + ui.label(RichText::new(crate::tl!("No saved layer states")).color(t.text_dim)); } }); let has = st.state_sel.is_some(); - if ui.add_enabled(has, egui::Button::new("Restore")).clicked() + if ui.add_enabled(has, egui::Button::new(crate::tl!("Restore"))).clicked() && let Some(s) = &st.state_sel { actions.push(("layerstate.restore", json!({ "name": s }))); } - if ui.add_enabled(has, egui::Button::new("Delete")).clicked() + if ui.add_enabled(has, egui::Button::new(crate::tl!("Delete"))).clicked() && let Some(s) = st.state_sel.take() { actions.push(("layerstate.delete", json!({ "name": s }))); } - ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text("New state name").desired_width(120.0)); - if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new("Save State")).clicked() { + ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)); + if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new(crate::tl!("Save State"))).clicked() { let n = st.state_name.trim().to_string(); actions.push(("layerstate.save", json!({ "name": n }))); st.state_sel = Some(n); st.state_name.clear(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut st.search).hint_text("Search for layer").desired_width(160.0)); + ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)); icons::paint( ui.painter(), Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)), @@ -315,18 +319,18 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { // ----- filter tree ----- ui.allocate_ui_with_layout(vec2(150.0, avail), egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_width(150.0); - ui.label(RichText::new("Filters").small().color(t.text_dim)); + ui.label(RichText::new(crate::tl!("Filters")).small().color(t.text_dim)); for (i, l) in ["All", "All Used Layers"].iter().enumerate() { ui.horizontal(|ui| { ui.add_space(if i == 0 { 0.0 } else { 14.0 }); - if ui.selectable_label(st.filter == i as u8, *l).clicked() { + if ui.selectable_label(st.filter == i as u8, crate::i18n::t(l)).clicked() { st.filter = i as u8; } }); } ui.add_space(8.0); if let Some((h, _)) = &vp { - ui.label(RichText::new(format!("Viewport {}", h.hex())).small().color(t.text_dim)); + ui.label(RichText::new(crate::tf!("Viewport {id}", id = h.hex())).small().color(t.text_dim)); } }); ui.separator(); @@ -345,11 +349,17 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { for h in &cols { if *h == "Name" { let arrow = if st.sort_desc { " ▼" } else { " ▲" }; - if ui.add(egui::Label::new(RichText::new(format!("Name{arrow}")).strong()).sense(Sense::click())).clicked() { + if ui + .add( + egui::Label::new(RichText::new(format!("{}{arrow}", crate::tl!("Name"))).strong()) + .sense(Sense::click()), + ) + .clicked() + { st.sort_desc = !st.sort_desc; } } else { - ui.label(RichText::new(*h).strong()); + ui.label(RichText::new(crate::i18n::t(h)).strong()); } } ui.end_row(); @@ -365,13 +375,17 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.separator(); ui.horizontal(|ui| { ui.label( - RichText::new(format!("All: {} layer(s) displayed of {} total. Current layer: {}", shown.len(), total, cur)) - .small() - .color(t.text_dim), + RichText::new(crate::tf!( + "Layers displayed: {shown} of {total}. Current layer: {layer}", + shown = shown.len(), + total = total, + layer = cur + )) + .small() + .color(t.text_dim), ); }); - }, - ); + }); ctx.data_mut(|dd| dd.insert_temp(sid, st)); for (c, p) in actions { let _ = app.run(c, p); @@ -408,7 +422,7 @@ fn row( } else { "Layer is empty (double-click to make current)" }; - if sresp.on_hover_text(tip).double_clicked() { + if sresp.on_hover_text(crate::i18n::t(tip)).double_clicked() { actions.push(("layer.current", json!({ "name": name }))); } // Name (double-click or F2-style rename). @@ -444,15 +458,15 @@ fn row( st.renaming = Some((name.clone(), name.clone())); } r.context_menu(|ui| { - if ui.button("Set Current").clicked() { + if ui.button(crate::tl!("Set Current")).clicked() { actions.push(("layer.current", json!({ "name": name }))); ui.close(); } - if ui.button("Rename Layer").clicked() { + if ui.button(crate::tl!("Rename Layer")).clicked() { st.renaming = Some((name.clone(), name.clone())); ui.close(); } - if ui.button("Delete Layer").clicked() { + if ui.button(crate::tl!("Delete Layer")).clicked() { actions.push(("layer.delete", json!({ "name": name }))); ui.close(); } @@ -503,7 +517,7 @@ fn row( .width(240.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - ui.label(RichText::new("Loaded linetypes").small().color(t.text_dim)); + ui.label(RichText::new(crate::tl!("Loaded linetypes")).small().color(t.text_dim)); for lt in linetypes.iter().filter(|x| !["bylayer", "byblock"].contains(&x.name.to_ascii_lowercase().as_str())) { let (r, resp) = ui.allocate_exact_size(vec2(230.0, 20.0), Sense::click()); if resp.hovered() || lt.name.eq_ignore_ascii_case(&l.linetype) { @@ -529,7 +543,7 @@ fn row( } }); ui.separator(); - if ui.button("Load all").clicked() { + if ui.button(crate::tl!("Load all")).clicked() { actions.push(("linetype", json!({ "load": "*" }))); ui.close(); } @@ -544,7 +558,13 @@ fn row( ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.5)); } lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 3.0, r.top()), vec2(22.0, r.height())), l.lineweight, t.text); - ui.painter().text(pos2(r.left() + 30.0, r.center().y), egui::Align2::LEFT_CENTER, l.lineweight.name(), crate::theme::body(), t.text); + ui.painter().text( + pos2(r.left() + 30.0, r.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(&l.lineweight.name()), + crate::theme::body(), + t.text, + ); resp }) .inner; @@ -561,7 +581,13 @@ fn row( ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(if resp.hovered() { 0.6 } else { 0.3 })); } lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 4.0, r.top()), vec2(40.0, r.height())), lw, t.text); - ui.painter().text(pos2(r.left() + 52.0, r.center().y), egui::Align2::LEFT_CENTER, lw.name(), crate::theme::body(), t.text); + ui.painter().text( + pos2(r.left() + 52.0, r.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(&lw.name()), + crate::theme::body(), + t.text, + ); if resp.clicked() { let v = match lw { Lineweight::Mm100(v) => json!(f64::from(v) / 100.0), @@ -598,7 +624,7 @@ fn row( if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } }))); } - if vc.is_some() && ui.small_button("×").on_hover_text("Remove the viewport override").clicked() { + if vc.is_some() && ui.small_button("×").on_hover_text(crate::tl!("Remove the viewport override")).clicked() { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): Value::Null } }))); } }); @@ -632,7 +658,7 @@ fn row( _ => { let text = if l.description.is_empty() { RichText::new("—").color(t.text_faint) } else { RichText::new(&l.description) }; let r = ui.add_sized([200.0, 20.0], egui::Label::new(text).sense(Sense::click()).truncate()); - if r.on_hover_text("Double-click to edit the description").double_clicked() { + if r.on_hover_text(crate::tl!("Double-click to edit the description")).double_clicked() { st.describing = Some((name.clone(), l.description.clone())); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c6ed3ae..f09f1e2a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod credits; pub mod dialogs; pub mod dyninput; pub mod gpu; +pub mod i18n; pub mod icons; pub mod layers; pub mod managers; @@ -42,6 +43,7 @@ pub const PREFS_KEY: &str = "cadcraft.prefs"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + pub interface_language: i18n::Preference, pub show_toolsets: bool, pub show_palettes: bool, pub show_toolbar: bool, @@ -67,6 +69,7 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + interface_language: i18n::Preference::default(), show_toolsets: true, show_palettes: true, show_toolbar: true, @@ -96,6 +99,7 @@ pub struct Services { } pub struct CadApp { + pub system_languages: Vec, pub session: Session, pub ui: UiState, pub services: Services, @@ -126,6 +130,7 @@ pub struct CadApp { impl CadApp { pub fn new(session: Session, services: Services) -> Self { CadApp { + system_languages: Vec::new(), session, ui: UiState::default(), services, @@ -149,6 +154,10 @@ impl CadApp { } } + pub fn language(&self) -> &'static str { + self.ui.interface_language.resolve(&self.system_languages) + } + /// Draw the canvas on the GPU with the app's wgpu render state (eframe's /// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU. pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) { @@ -211,7 +220,7 @@ impl CadApp { /// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]). pub fn prefs_json(&self) -> String { - json!({ "theme": self.ui.theme.as_str() }).to_string() + json!({ "theme": self.ui.theme.as_str(), "interfaceLanguage": self.ui.interface_language.code() }).to_string() } /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. @@ -220,6 +229,9 @@ impl CadApp { if let Some(t) = v.get("theme").and_then(Value::as_str).and_then(theme::ThemePref::parse) { self.ui.theme = t; } + if let Some(l) = v.get("interfaceLanguage").and_then(Value::as_str).and_then(i18n::Preference::parse) { + self.ui.interface_language = l; + } } pub fn set_status(&mut self, s: impl Into) { @@ -291,6 +303,10 @@ impl CadApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::with_language(self.language(), || self.draw(ui)); + } + + fn draw(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); let t = theme::Tokens::get(); chrome::title_and_toolbar(self, ui); @@ -461,6 +477,6 @@ mod tests { frame(&mut a, &ctx, None); assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback"); assert_eq!(a.ui.theme, ThemePref::System); - assert_eq!(a.prefs_json(), r#"{"theme":"system"}"#); + assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","theme":"system"}"#); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3e6d4fb..a6fb240a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -38,6 +38,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), ("ui.toggle.history", "Command History", &["Window", "Command History"], Some("F2")), ("ui.cmdline.lines", "Command Line History Lines", &[], None), + ("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None), + ("ui.language", "Language", &[], None), ("ui.noop", "", &[], None), ("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")), ]; @@ -48,6 +50,10 @@ pub fn is_ui_command(id: &str) -> bool { /// Run a UI-only command. `None` if `id` isn't one. pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option> { + crate::i18n::with_language(app.language(), || run_ui_command_inner(app, id, params)) +} + +fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option> { if let Some(r) = crate::managers::route(app, id, params) { return Some(r); } @@ -60,6 +66,13 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { + let Some(preference) = params.get("lang").and_then(Value::as_str).and_then(crate::i18n::Preference::parse) else { + return Some(Err("language must be auto, en or uk".into())); + }; + app.ui.interface_language = preference; + Ok(json!({"interfaceLanguage": preference.code()})) + } "ui.open" | "open" if no_path => { let picked = app.services.pick_open.as_ref().and_then(|f| f()); if let Some(p) = picked { @@ -152,8 +165,8 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { - let (menu, theme) = (app.ui.in_window_menu, app.ui.theme); - app.ui = crate::UiState { in_window_menu: menu, theme, ..Default::default() }; + let (menu, theme, interface_language) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language); + app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, ..Default::default() }; Ok(Value::Null) } "ui.start" => { @@ -173,7 +186,8 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option String { fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { match e { Entry::Item { label, id, shortcut, enabled } => { - let mut b = egui::Button::new(label); + let mut b = egui::Button::new(crate::i18n::t(label)); if let Some(s) = shortcut { b = b.shortcut_text(shortcut_label(s, ui.ctx().os().is_mac())); } @@ -326,7 +340,7 @@ fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { } } Entry::Sub { label, children } => { - ui.menu_button(label, |ui| { + ui.menu_button(crate::i18n::t(label), |ui| { for c in children { entry_ui(ui, c, clicked); } @@ -344,7 +358,7 @@ pub fn menu_bar(app: &mut CadApp, ui: &mut egui::Ui) { |ui| { egui::MenuBar::new().ui(ui, |ui| { for (name, entries) in &tree { - ui.menu_button(name, |ui| { + ui.menu_button(crate::i18n::t(name), |ui| { for e in entries { entry_ui(ui, e, &mut clicked); } diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 347329ff..80e39103 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -133,7 +133,13 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let active = app.ui.toolset_tab == name; let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click()); p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome }); - p.text(tr.center(), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(13.5), if active { t.text } else { t.text_dim }); + p.text( + tr.center(), + egui::Align2::CENTER_CENTER, + crate::i18n::t(name), + egui::FontId::proportional(13.5), + if active { t.text } else { t.text_dim }, + ); if resp.clicked() { app.ui.toolset_tab = name.into(); } @@ -143,7 +149,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronLeft, false); - if cresp.on_hover_text("Collapse Tool Sets").clicked() { + if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() { app.ui.show_toolsets = false; } let body = Rect::from_min_max(pos2(r.left(), r.top() + tab_h), r.max); @@ -164,7 +170,13 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { if collapsed { Icon::ChevronRight } else { Icon::ChevronDown }, false, ); - pp.text(pos2(hr.left() + 22.0, hr.center().y), egui::Align2::LEFT_CENTER, *name, egui::FontId::proportional(12.5), t.text); + pp.text( + pos2(hr.left() + 22.0, hr.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(name), + egui::FontId::proportional(12.5), + t.text, + ); icons::paint(pp, Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(11.0, 11.0)), Icon::Gear, false); if hresp.clicked() { toggle_group = Some(name.to_string()); @@ -239,7 +251,13 @@ pub const MODELING: &[(&str, &[Tool], &[Tool])] = &[ fn section_header(ui: &mut egui::Ui, title: &str) -> Rect { let t = Tokens::get(); let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 10.0, hr.center().y), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(14.0), t.text); + ui.painter().text( + pos2(hr.left() + 10.0, hr.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(title), + egui::FontId::proportional(14.0), + t.text, + ); hr } @@ -359,7 +377,13 @@ fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { let w = ui.available_width() - 10.0; let (rect, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover()); ui.painter().rect_filled(rect, 3.0, t.control); - ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), egui::Align2::LEFT_CENTER, "Unsaved Layer State", crate::theme::body(), t.text); + ui.painter().text( + pos2(rect.left() + 8.0, rect.center().y), + egui::Align2::LEFT_CENTER, + crate::tl!("Unsaved Layer State"), + crate::theme::body(), + t.text, + ); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::ChevronDown, false); }); ui.add_space(4.0); @@ -373,7 +397,7 @@ fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { if app.ui.show_layer_list { Icon::ChevronDown } else { Icon::ChevronRight }, false, ); - ui.painter().text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, label, crate::theme::body(), t.text); + ui.painter().text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text); if resp.clicked() { app.ui.show_layer_list = !app.ui.show_layer_list; } @@ -411,8 +435,11 @@ fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(); ui.horizontal(|ui| { - let (lr, _) = ui.allocate_exact_size(vec2(118.0, 22.0), Sense::hover()); - ui.painter().text(pos2(lr.right() - 6.0, lr.center().y), egui::Align2::RIGHT_CENTER, label, crate::theme::body(), t.text_dim); + let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0); + job.halign = egui::Align::RIGHT; + let galley = ui.painter().layout_job(job); + let (lr, _) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + ui.painter().galley(pos2(lr.right() - 6.0, lr.center().y - galley.size().y / 2.0), galley, t.text_dim); add(ui); }); } @@ -460,10 +487,12 @@ fn edit_field(ui: &mut egui::Ui, id: egui::Id, value: String) -> Option /// A drop-down of `items` showing `current`; returns the item clicked. fn pick_menu(ui: &mut egui::Ui, current: &str, items: impl IntoIterator) -> Option { + // Names are the drawing's own; only the ByLayer/ByBlock keywords are translated. + let shown = |s: &str| if ["ByLayer", "ByBlock"].contains(&s) { crate::i18n::t(s).to_string() } else { s.to_string() }; let mut out = None; - ui.menu_button(current.to_string(), |ui| { + ui.menu_button(shown(current), |ui| { for it in items { - if ui.button(&it).clicked() { + if ui.button(shown(&it)).clicked() { out = Some(it); ui.close(); } @@ -474,12 +503,12 @@ fn pick_menu(ui: &mut egui::Ui, current: &str, items: impl IntoIterator "No selection".to_string(), - (1, [(n, _)]) => n.to_string(), - (n, [(ty, _)]) => format!("{ty} ({n})"), - (n, _) => format!("All ({n})"), + (0, _) => crate::tl!("No selection").to_string(), + (1, [(n, _)]) => crate::i18n::t(n).to_string(), + (n, [(ty, _)]) => format!("{} ({n})", crate::i18n::t(ty)), + (n, _) => format!("{} ({n})", crate::tl!("All")), }; let mut run: Option<(&str, Value)> = None; let mut start: Option<&str> = None; @@ -528,20 +557,24 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { // Narrow the selection to one object type. egui::Popup::from_toggle_button_response(&resp).width(w - 10.0).close_behavior(egui::PopupCloseBehavior::CloseOnClick).show(|ui| { for (ty, hs) in &types { - if ui.button(format!("{ty} ({})", hs.len())).clicked() { + if ui.button(format!("{} ({})", crate::i18n::t(ty), hs.len())).clicked() { run = Some(("select", json!({ "handles": hs }))); } } }); } - let tip = if pickadd { "Toggle PICKADD (on: picks add to the selection)" } else { "Toggle PICKADD (off: each pick replaces the selection)" }; + let tip = if pickadd { + crate::tl!("Toggle PICKADD (on: picks add to the selection)") + } else { + crate::tl!("Toggle PICKADD (off: each pick replaces the selection)") + }; if icons::button(ui, Icon::PickAdd, 22.0, tip, pickadd).clicked() { run = Some(("setvar", json!({ "name": "PICKADD", "value": i32::from(!pickadd) }))); } - if icons::button(ui, Icon::SelectObjects, 22.0, "Select Objects", false).clicked() { + if icons::button(ui, Icon::SelectObjects, 22.0, crate::tl!("Select Objects"), false).clicked() { start = Some("select"); } - if icons::button(ui, Icon::QuickSelect, 22.0, "Quick Select", false).clicked() { + if icons::button(ui, Icon::QuickSelect, 22.0, crate::tl!("Quick Select"), false).clicked() { start = Some("ui.dialog.qselect"); } }); @@ -571,7 +604,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let mut action: Option<(&str, Value)> = None; let mut act = |c: &'static str, p: Value| action = Some((c, p)); prop_row(ui, "Color", |ui| { - ui.menu_button(format!("■ {}", Color::from_aci(h.i64("CECOLOR", 256) as i16).name()), |ui| { + ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| { for (n, c) in [ ("ByLayer", 256), ("ByBlock", 0), @@ -583,7 +616,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { ("Magenta", 6), ("White", 7), ] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { act("color", json!({ "color": c })); ui.close(); } @@ -612,9 +645,9 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); prop_row(ui, "Lineweight", |ui| { - ui.menu_button(cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16).name(), |ui| { + ui.menu_button(crate::i18n::t(&cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16).name()), |ui| { for n in ["ByLayer", "ByBlock", "Default"] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { act("lweight", json!({ "lineweight": n })); ui.close(); } @@ -695,11 +728,11 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let group = |ui: &mut egui::Ui, title: &str| { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); ui.painter().rect_filled(r.shrink2(vec2(6.0, 1.0)), 2.0, t.chrome_dark); - ui.painter().text(pos2(r.left() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::small(), t.text_dim); + ui.painter().text(pos2(r.left() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(title), crate::theme::small(), t.text_dim); }; group(ui, "General"); prop_row(ui, "Color", |ui| { - ui.menu_button(format!("■ {color}"), |ui| { + ui.menu_button(format!("■ {}", crate::i18n::t(&color)), |ui| { for (n, c) in [ ("ByLayer", 256), ("ByBlock", 0), @@ -713,7 +746,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { ("Color 8", 8), ("Color 9", 9), ] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { set = Some(json!({ "handles": ids, "color": c })); ui.close(); } @@ -731,12 +764,12 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Linetype", |ui| { - ui.menu_button(linetype.clone(), |ui| { + ui.menu_button(if ["ByLayer", "ByBlock", "*VARIES*"].contains(&linetype.as_str()) { crate::i18n::t(&linetype) } else { &linetype }, |ui| { for l in std::iter::once("ByLayer".to_string()) .chain(std::iter::once("ByBlock".to_string())) .chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned()) { - if ui.button(&l).clicked() { + if ui.button(if ["ByLayer", "ByBlock"].contains(&l.as_str()) { crate::i18n::t(&l) } else { &l }).clicked() { set = Some(json!({ "handles": ids, "linetype": l })); ui.close(); } @@ -751,9 +784,9 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); prop_row(ui, "Lineweight", |ui| { - ui.menu_button(lw.clone(), |ui| { + ui.menu_button(crate::i18n::t(&lw), |ui| { for n in ["ByLayer", "ByBlock", "Default"] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { set = Some(json!({ "handles": ids, "lineweight": n })); ui.close(); } @@ -767,9 +800,9 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Transparency", |ui| { - ui.menu_button(tr.clone(), |ui| { + ui.menu_button(crate::i18n::t(&tr), |ui| { for v in ["ByLayer", "ByBlock"] { - if ui.button(v).clicked() { + if ui.button(crate::i18n::t(v)).clicked() { set = Some(json!({ "handles": ids, "transparency": v })); ui.close(); } @@ -780,7 +813,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let cur = tr.parse::().ok(); let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); ui.horizontal(|ui| { - ui.label("Percent"); + ui.label(crate::tl!("Percent")); let r = ui.add(egui::DragValue::new(&mut pct).range(0..=90).speed(0.5)); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(tid, pct)); @@ -894,7 +927,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, "Closed", |ui| { let mut c = pl.closed; let label = if c { "Yes" } else { "No" }; - if ui.checkbox(&mut c, label).changed() { + if ui.checkbox(&mut c, crate::i18n::t(label)).changed() { set = Some(json!({ "handles": ids, "closed": c })); } }); diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index 8efc836b..f1d59fec 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -360,7 +360,12 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter if resp.double_clicked() { act = Some(Act::Edit(c.name.clone(), c.expr.clone(), r.left_center())); } - resp.on_hover_text(format!("{} constraint {} = {} (double-click to edit)", c.kind.name(), c.name, c.expr)); + resp.on_hover_text(crate::tf!( + "{kind} constraint {name} = {expression} (double-click to edit)", + kind = crate::i18n::t(c.kind.name()), + name = c.name, + expression = c.expr + )); } // ----- geometric constraint bars ----- let mut bars: Vec<(Handle, Vec2, Vec<(&str, u32)>)> = Vec::new(); @@ -425,7 +430,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_filled(cr.shrink(1.0), 3.0, Color32::from_rgb(0xf2, 0xf4, 0xf7)); } paint_glyph(painter, cr.shrink(2.0), kind, Color32::from_rgb(0x26, 0x2b, 0x33)); - resp.on_hover_text(format!("{kind} (constraint {id})")); + resp.on_hover_text(crate::tf!("{kind} (constraint {id})", kind = crate::i18n::t(kind), id = id)); } } let _ = act_take(app, act, ui); @@ -514,57 +519,66 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) let filter_id = egui::Id::new("par_filter"); let mut filter = ctx.data_mut(|d| d.get_temp::(filter_id)).unwrap_or_default(); let mut action: Option = None; - egui::Window::new("Parameters Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("ƒx New User Parameter").on_hover_text("Creates a user parameter").clicked() { - let mut n = 1; - while rows.iter().any(|r| r.name == format!("user{n}")) { - n += 1; - } - action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text("Search for parameter").desired_width(170.0)); - }); - }); - ui.separator(); - let f = filter.to_ascii_lowercase(); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - egui::Grid::new("par_grid").striped(true).num_columns(5).spacing(vec2(12.0, 4.0)).min_col_width(60.0).show(ui, |ui| { - for h in ["Name", "Expression", "Value", "Type", ""] { - ui.label(egui::RichText::new(h).strong()); - } - ui.end_row(); - for (kind, title) in [("dimensional", "Dimensional Constraint Parameters"), ("user", "User Parameters")] { - let group: Vec<&Row> = - rows.iter().filter(|r| r.kind == kind && (f.is_empty() || r.name.to_ascii_lowercase().contains(&f))).collect(); - ui.label(egui::RichText::new(title).color(t.text_dim).small()); - ui.end_row(); - for r in group { - ui.label(&r.name); - let id = egui::Id::new(("par_expr", &r.name)); - if let Some(v) = expr_field(ui, id, &r.expr) { - action = Some(json!({ "name": r.name, "expr": v })); - } - match (&r.value, &r.error) { - (Some(v), _) => ui.label(trim_num(*v, 4)), - (None, Some(e)) => ui.label(egui::RichText::new("error").color(Color32::from_rgb(0xff, 0x8a, 0x6a))).on_hover_text(e), - _ => ui.label(""), - }; - ui.label(egui::RichText::new(if kind == "user" { "User" } else { r.description.as_str() }).color(t.text_dim)); - if kind == "user" { - if ui.small_button("Delete").clicked() { - action = Some(json!({ "delete": r.name })); - } - } else { - ui.label(""); - } - ui.end_row(); + egui::Window::new(crate::tl!("Parameters Manager")) + .id(egui::Id::new("Parameters Manager")) + .open(open) + .default_size(vec2(560.0, 300.0)) + .resizable(true) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button(crate::tl!("ƒx New User Parameter")).on_hover_text(crate::tl!("Creates a user parameter")).clicked() { + let mut n = 1; + while rows.iter().any(|r| r.name == format!("user{n}")) { + n += 1; } + action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)); + }); + }); + ui.separator(); + let f = filter.to_ascii_lowercase(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Grid::new("par_grid").striped(true).num_columns(5).spacing(vec2(12.0, 4.0)).min_col_width(60.0).show(ui, |ui| { + for h in ["Name", "Expression", "Value", "Type", ""] { + ui.label(egui::RichText::new(crate::i18n::t(h)).strong()); + } + ui.end_row(); + for (kind, title) in [("dimensional", "Dimensional Constraint Parameters"), ("user", "User Parameters")] { + let group: Vec<&Row> = + rows.iter().filter(|r| r.kind == kind && (f.is_empty() || r.name.to_ascii_lowercase().contains(&f))).collect(); + ui.label(egui::RichText::new(crate::i18n::t(title)).color(t.text_dim).small()); + ui.end_row(); + for r in group { + ui.label(&r.name); + let id = egui::Id::new(("par_expr", &r.name)); + if let Some(v) = expr_field(ui, id, &r.expr) { + action = Some(json!({ "name": r.name, "expr": v })); + } + match (&r.value, &r.error) { + (Some(v), _) => ui.label(trim_num(*v, 4)), + (None, Some(e)) => { + ui.label(egui::RichText::new(crate::tl!("error")).color(Color32::from_rgb(0xff, 0x8a, 0x6a))).on_hover_text(e) + } + _ => ui.label(""), + }; + ui.label( + egui::RichText::new(crate::i18n::t(if kind == "user" { "User" } else { r.description.as_str() })).color(t.text_dim), + ); + if kind == "user" { + if ui.small_button(crate::tl!("Delete")).clicked() { + action = Some(json!({ "delete": r.name })); + } + } else { + ui.label(""); + } + ui.end_row(); + } + } + }); }); }); - }); ctx.data_mut(|d| d.insert_temp(filter_id, filter)); if let Some(p) = action { let _ = app.run("parameters", p); diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 29797b76..7211b832 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -76,132 +76,141 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let has_sel = !app.session.selection().is_empty(); let mut run: Option = None; let mut close = false; - egui::Window::new("Quick Select").open(open).collapsible(false).resizable(false).default_width(380.0).show(ctx, |ui| { - egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Apply to:"); - egui::ComboBox::from_id_salt("qs_apply") - .width(220.0) - .selected_text(if st.apply_selection { "Current selection" } else { "Entire drawing" }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut st.apply_selection, false, "Entire drawing"); - ui.add_enabled_ui(has_sel, |ui| { - ui.selectable_value(&mut st.apply_selection, true, "Current selection"); + egui::Window::new(crate::tl!("Quick Select")) + .id(egui::Id::new("Quick Select")) + .open(open) + .collapsible(false) + .resizable(false) + .default_width(380.0) + .show(ctx, |ui| { + egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(crate::tl!("Apply to:")); + egui::ComboBox::from_id_salt("qs_apply") + .width(220.0) + .selected_text(crate::i18n::t(if st.apply_selection { "Current selection" } else { "Entire drawing" })) + .show_ui(ui, |ui| { + ui.selectable_value(&mut st.apply_selection, false, crate::tl!("Entire drawing")); + ui.add_enabled_ui(has_sel, |ui| { + ui.selectable_value(&mut st.apply_selection, true, crate::tl!("Current selection")); + }); }); + ui.end_row(); + ui.label(crate::tl!("Object type:")); + let types: Vec<(String, u64)> = st + .info + .get("types") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| Some((v.get("type")?.as_str()?.to_string(), v.get("count").and_then(Value::as_u64).unwrap_or(0)))) + .collect() + }) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| { + ui.selectable_value(&mut st.ty, "Multiple".to_string(), crate::tl!("Multiple")); + for (n, c) in &types { + ui.selectable_value(&mut st.ty, n.clone(), format!("{} ({c})", crate::i18n::t(n))); + } }); - ui.end_row(); - ui.label("Object type:"); - let types: Vec<(String, u64)> = st - .info - .get("types") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| Some((v.get("type")?.as_str()?.to_string(), v.get("count").and_then(Value::as_u64).unwrap_or(0)))) - .collect() - }) - .unwrap_or_default(); - egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(st.ty.clone()).show_ui(ui, |ui| { - ui.selectable_value(&mut st.ty, "Multiple".to_string(), "Multiple"); - for (n, c) in &types { - ui.selectable_value(&mut st.ty, n.clone(), format!("{n} ({c})")); - } - }); - ui.end_row(); - ui.label("Properties:"); - let props: Vec = st - .info - .get("properties") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) - .unwrap_or_default(); - egui::Frame::NONE.fill(t.chrome_dark).corner_radius(3).inner_margin(4).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { - ui.set_width(212.0); - ui.vertical(|ui| { - for p in &props { - if ui.selectable_label(st.property == *p, pretty(p)).clicked() { - st.property = p.clone(); - } - } - }); - }); - }); - ui.end_row(); - ui.label("Operator:"); - let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals"); - egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(op_label).show_ui(ui, |ui| { - for (o, l) in OPERATORS { - ui.selectable_value(&mut st.operator, o.to_string(), *l); - } - }); - ui.end_row(); - ui.label("Value:"); - let choices: Vec = match st.property.as_str() { - "color" => { - ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect() - } - "layer" => app.session.doc().map(|d| d.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), - "linetype" => app.session.doc().map(|d| d.linetypes.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), - "type" => types.iter().map(|(n, _)| n.clone()).collect(), - _ => Vec::new(), - }; - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); - if !choices.is_empty() { - ui.menu_button("▼", |ui| { - egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { - for c in &choices { - if ui.button(c).clicked() { - st.value = c.clone(); - ui.close(); + ui.end_row(); + ui.label(crate::tl!("Properties:")); + let props: Vec = st + .info + .get("properties") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::Frame::NONE.fill(t.chrome_dark).corner_radius(3).inner_margin(4).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + ui.set_width(212.0); + ui.vertical(|ui| { + for p in &props { + if ui.selectable_label(st.property == *p, crate::i18n::t(&pretty(p))).clicked() { + st.property = p.clone(); } } }); }); - } - }); - ui.end_row(); - }); - ui.separator(); - ui.label("How to apply:"); - ui.radio_value(&mut st.exclude, false, "Include in new selection set"); - ui.radio_value(&mut st.exclude, true, "Exclude from new selection set"); - ui.checkbox(&mut st.append, "Append to current selection set"); - ui.separator(); - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(" OK ").clicked() { - let mode = if st.exclude { - "exclude" - } else if st.append { - "append" - } else { - "new" - }; - let mut p = json!({ - "applyTo": if st.apply_selection { "selection" } else { "drawing" }, - "mode": mode, - }); - if let Some(o) = p.as_object_mut() { - if st.ty != "Multiple" { - o.insert("type".into(), json!(st.ty)); - } - if !st.property.is_empty() && (!st.value.trim().is_empty() || st.operator == "*") { - o.insert("property".into(), json!(st.property)); - o.insert("operator".into(), json!(st.operator)); - let v = st.value.trim(); - o.insert("value".into(), v.parse::().map(|f| json!(f)).unwrap_or_else(|_| json!(v))); - } + }); + ui.end_row(); + ui.label(crate::tl!("Operator:")); + let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals"); + egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| { + for (o, l) in OPERATORS { + ui.selectable_value(&mut st.operator, o.to_string(), crate::i18n::t(l)); } - run = Some(p); - close = true; - } - if ui.button("Cancel").clicked() { - close = true; - } + }); + ui.end_row(); + ui.label(crate::tl!("Value:")); + let choices: Vec = match st.property.as_str() { + "color" => { + ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect() + } + "layer" => app.session.doc().map(|d| d.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), + "linetype" => app.session.doc().map(|d| d.linetypes.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), + "type" => types.iter().map(|(n, _)| n.clone()).collect(), + _ => Vec::new(), + }; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); + if !choices.is_empty() { + ui.menu_button("▼", |ui| { + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { + for c in &choices { + if ui + .button(if ["color", "type"].contains(&st.property.as_str()) { crate::i18n::t(c) } else { c.as_str() }) + .clicked() + { + st.value = c.clone(); + ui.close(); + } + } + }); + }); + } + }); + ui.end_row(); + }); + ui.separator(); + ui.label(crate::tl!("How to apply:")); + ui.radio_value(&mut st.exclude, false, crate::tl!("Include in new selection set")); + ui.radio_value(&mut st.exclude, true, crate::tl!("Exclude from new selection set")); + ui.checkbox(&mut st.append, crate::tl!("Append to current selection set")); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(crate::tl!(" OK ")).clicked() { + let mode = if st.exclude { + "exclude" + } else if st.append { + "append" + } else { + "new" + }; + let mut p = json!({ + "applyTo": if st.apply_selection { "selection" } else { "drawing" }, + "mode": mode, + }); + if let Some(o) = p.as_object_mut() { + if st.ty != "Multiple" { + o.insert("type".into(), json!(st.ty)); + } + if !st.property.is_empty() && (!st.value.trim().is_empty() || st.operator == "*") { + o.insert("property".into(), json!(st.property)); + o.insert("operator".into(), json!(st.operator)); + let v = st.value.trim(); + o.insert("value".into(), v.parse::().map(|f| json!(f)).unwrap_or_else(|_| json!(v))); + } + } + run = Some(p); + close = true; + } + if ui.button(crate::tl!("Cancel")).clicked() { + close = true; + } + }); }); }); - }); ctx.data_mut(|d| d.insert_temp(sid, st)); if let Some(p) = run { let _ = app.run("qselect", p); @@ -220,7 +229,7 @@ struct QpState { } fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) { - ui.label(RichText::new(label).color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); let s = format!("{v:.4}"); if let Some(n) = crate::parametric::expr_field(ui, id, &s) && let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::().ok()).filter(|f| f.is_finite()) @@ -231,7 +240,7 @@ fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Optio } fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { - ui.label(RichText::new(label).color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); ui.label(v); ui.end_row(); } @@ -271,9 +280,9 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { let title = if ents.len() == 1 { ty.to_string() } else if same_type { - format!("{ty} ({})", ents.len()) + format!("{} ({})", crate::i18n::t(ty), ents.len()) } else { - format!("All ({})", ents.len()) + format!("{} ({})", crate::tl!("All"), ents.len()) }; let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String { let v0 = ents.first().map(|e| f(e)).unwrap_or_default(); @@ -297,13 +306,13 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { .default_width(250.0) .show(ctx, |ui| { egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| { - ui.label(RichText::new("Color").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim)); let cur = color.unwrap_or(cadcraft_color::Color::ByLayer); if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) { set = Some(("color".into(), json!(c.name()))); } ui.end_row(); - ui.label(RichText::new("Layer").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Layer")).color(Tokens::get().text_dim)); egui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| { for l in &layers { if ui.selectable_label(*l == layer, l).clicked() { @@ -312,7 +321,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label(RichText::new("Linetype").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Linetype")).color(Tokens::get().text_dim)); egui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| { for l in std::iter::once("ByLayer".to_string()) .chain(std::iter::once("ByBlock".to_string())) @@ -359,7 +368,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width"); } Some(EntityKind::Text(tx)) => { - ui.label(RichText::new("Contents").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) { set = Some(("text".into(), json!(v))); } @@ -368,7 +377,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Rotation", id("rot"), tx.rotation.to_degrees(), &mut set, "rotation"); } Some(EntityKind::MText(tx)) => { - ui.label(RichText::new("Contents").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) { set = Some(("text".into(), json!(v))); } @@ -381,7 +390,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale"); } Some(EntityKind::Dimension(dm)) => { - ui.label(RichText::new("Text override").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) { set = Some(("textOverride".into(), json!(v))); } @@ -392,7 +401,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { }); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Close").on_hover_text("Turn Quick Properties off (QPMODE)").clicked() { + if ui.small_button(crate::tl!("Close")).on_hover_text(crate::tl!("Turn Quick Properties off (QPMODE)")).clicked() { close = true; } }); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6acc01f2..51926822 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -33,3 +33,16 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session. + +## Interface language + +Select a language with `engine.execute` and the UI command `ui.language`: + +```json +{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"uk"}}} +``` + +Supported values are `auto`, `en` and `uk`. `ui.set` also accepts +`{"interfaceLanguage":"uk"}`; `ui.inspect` reports the saved choice in its UI state. +Invalid choices leave the current language unchanged. Menus, command labels and prompt text +returned by the control channel retain their English source text. See [localization.md](localization.md). diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 00000000..395ac056 --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,66 @@ +# Interface languages + +Choose **Edit → Interface Language** and select **Auto (system language)**, **English**, or +**Українська**. The interface updates without restarting, including the native macOS menus. +The choice is saved with the other interface preferences (`cadcraft.prefs`, beside the theme): +eframe storage on the desktop, browser local storage on the web. Installations without a saved +choice use Auto. + +Auto uses the first supported language in the operating system's preferred locales, or the +browser's `navigator.languages` on the web. Ukrainian tags such as `uk-UA` and `uk_UA.UTF-8` +resolve to `uk`; unsupported languages fall back to English. Set `CADCRAFT_LOCALE=uk-UA` to +override the desktop's locale for a launch. An explicit saved English or Ukrainian choice +takes precedence over that override. + +Automation can select the language through the existing control channel: + +```json +{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"uk"}}} +``` + +The `ui.set` method also accepts `{"interfaceLanguage":"uk"}`. Supported values are `auto`, +`en`, and `uk`; invalid values are rejected without changing the current selection. Resetting +palettes keeps the language choice. + +## Translation coverage + +Ukrainian covers command and menu labels, tool groups and tooltips, the Start page, file and +model-space tabs, status controls, object snaps, drafting prompts, property labels, dialogs, +and the About window's controls. The command reference can be searched by English command +ID, English label, or Ukrainian label. + +Drawing titles, layer/layout/block/style names, parameter names and expressions, text in a +drawing, and command-line input are user data and retain their original spelling. Clickable +command keywords retain their English spelling so the text shown matches the input accepted +by the engine. CLI/MCP/control responses, engine diagnostics and command history remain +English. Uncatalogued text also falls back to English. Number formatting and the operating +system's own file-picker controls follow the existing behavior. + +The bundled egui fonts cover Ukrainian, including `Ґґ Єє Іі Її`; the translation adds no font +assets and works on the web without system fonts. + +## Adding translations + +The implementation follows the display-time catalog pattern used by +[VectorCraft #205](https://github.com/storytold/vectorcraft/pull/205), +[PdfCraft #106](https://github.com/storytold/pdfcraft/pull/106), and +[FilmCraft #238](https://github.com/storytold/filmcraft/pull/238). Its code is local to CADCraft. + +- `crates/ui-egui/src/i18n/uk.tsv` is UTF-8 with three tab-separated columns: + `context`, `English source`, and `translation`. Ordinary labels have an empty context. + `@plural` entries hold Ukrainian `one|few|many` forms for integer counts. +- Register a new catalog in `i18n::LANGUAGES`, add its preference variant and code/name, + and supply its integer plural rule if it uses `@plural` rows. +- Use `crate::tl!("English label")` for a literal, `i18n::t(label)` for a known dynamic UI + label, and `crate::tf!("Version {version}", version = value)` for named placeholders. + Translate only at the display boundary; never look up arbitrary user names or identifiers. +- Windows retain explicit IDs based on their English source, and tool groups retain their + source names as keys, so switching languages preserves their state. +- Catalogs are embedded and parsed once. Missing or malformed rows cannot crash the app; + tests reject invalid rows, duplicate keys, changed placeholders, and mismatched ellipses. +- `cargo test -p cadcraft-ui-egui i18n` checks menu/command/tool/snap coverage, every marked + UI literal, locale resolution, preferences, integer plurals, bundled glyphs and an actual + English/Ukrainian/English egui render. `cargo xtask ci` runs the workspace quality gates. + +Translations are original work under `MIT OR Apache-2.0`, written from the meaning of +CADCraft's English strings. Do not copy proprietary localization resources.