WASM plugins, scripting, i18n, GUI pan/zoom, cargo-dist CI/CD
- WASM plugin host (wasmtime 46, 17 host fns) + example noisemap plugin - Rhai scripting engine (12 API fns, `terrafier run` cmd) - i18n: en/ru translations, auto-detect, t! macro - GUI: pan (click-drag) + zoom (scroll wheel) with viewport transform - cargo-dist 0.31: MSI/pkg/shell installers, both binaries - CI: .github/workflows/ci.yml + release.yml - Security: wasmtime 30→46, bincode 1.3→2.0, quick-xml CVE docs - Code review: SAFETY comments, unwrap→match cleanup - Fixes: clippy approx_constant, needless_borrows, rustfmt
This commit is contained in:
parent
bdd4564240
commit
18613260a8
100 changed files with 4262 additions and 585 deletions
10
crates/i18n/Cargo.toml
Normal file
10
crates/i18n/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "terrafier-i18n"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Internationalization for Terrafier"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
8
crates/i18n/src/lang.rs
Normal file
8
crates/i18n/src/lang.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Language {
|
||||
English = 0,
|
||||
Russian = 1,
|
||||
}
|
||||
116
crates/i18n/src/lib.rs
Normal file
116
crates/i18n/src/lib.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
mod lang;
|
||||
pub use lang::Language;
|
||||
|
||||
// Language code: 0 = English, 1 = Russian
|
||||
static CURRENT_LANG: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
// Embedded translations
|
||||
type TransMap = HashMap<String, String>;
|
||||
|
||||
static EN: LazyLock<TransMap> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("translations/en.json"))
|
||||
.expect("Failed to parse English translations")
|
||||
});
|
||||
|
||||
static RU: LazyLock<TransMap> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("translations/ru.json"))
|
||||
.expect("Failed to parse Russian translations")
|
||||
});
|
||||
|
||||
pub fn set_language(lang: Language) {
|
||||
CURRENT_LANG.store(lang as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn language() -> Language {
|
||||
match CURRENT_LANG.load(Ordering::Relaxed) {
|
||||
1 => Language::Russian,
|
||||
_ => Language::English,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a translation for the given key.
|
||||
pub fn translate(key: &str) -> &str {
|
||||
let map = match CURRENT_LANG.load(Ordering::Relaxed) {
|
||||
1 => &*RU,
|
||||
_ => &*EN,
|
||||
};
|
||||
map.get(key).map(String::as_str).unwrap_or(key)
|
||||
}
|
||||
|
||||
/// Get a translation with variable substitution.
|
||||
/// Variables in the string are `{name}` placeholders.
|
||||
pub fn translate_with(key: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut s = translate(key).to_string();
|
||||
for (k, v) in vars {
|
||||
s = s.replace(&format!("{{{}}}", k), v);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Macro for simple translation lookup.
|
||||
#[macro_export]
|
||||
macro_rules! t {
|
||||
($key:expr) => {
|
||||
$crate::translate($key)
|
||||
};
|
||||
($key:expr, $($var:ident = $val:expr),+ $(,)?) => {{
|
||||
let vars: &[(&str, &str)] = &[$((stringify!($var), &$val as &dyn std::fmt::Display as &str)),+];
|
||||
// This won't work with runtime format. Use translate_with instead.
|
||||
$crate::translate_with($key, &[$((stringify!($var), &$val.as_ref() as &str)),+])
|
||||
}};
|
||||
}
|
||||
|
||||
/// Detect system language from environment variables (LANG, LANGUAGE, LC_ALL).
|
||||
pub fn detect_language() -> Language {
|
||||
for var in &["LANG", "LANGUAGE", "LC_ALL"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
let lower = val.to_lowercase();
|
||||
if lower.starts_with("ru") || lower.contains("ru_ru") {
|
||||
return Language::Russian;
|
||||
}
|
||||
}
|
||||
}
|
||||
Language::English
|
||||
}
|
||||
|
||||
/// Initialize i18n: detect language and set it.
|
||||
pub fn init() {
|
||||
let lang = detect_language();
|
||||
set_language(lang);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_language_is_english() {
|
||||
assert_eq!(translate("cli.new.creating"), "Creating new world...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_russian_language() {
|
||||
set_language(Language::Russian);
|
||||
assert_eq!(translate("cli.new.creating"), "Создание нового мира...");
|
||||
set_language(Language::English);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_key_returns_key() {
|
||||
assert_eq!(translate("nonexistent.key"), "nonexistent.key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_with_vars() {
|
||||
let result = translate_with("cli.new.created", &[
|
||||
("name", "test"),
|
||||
("tiles", "9"),
|
||||
]);
|
||||
assert!(result.contains("test"));
|
||||
assert!(result.contains("9"));
|
||||
}
|
||||
}
|
||||
30
crates/i18n/src/translations/en.json
Normal file
30
crates/i18n/src/translations/en.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"cli.new.creating": "Creating new world...",
|
||||
"cli.new.created": "World \"{name}\" created ({tiles} tiles)",
|
||||
"cli.export.exporting": "Exporting world to \"{path}\"...",
|
||||
"cli.export.complete": "Export complete: {regions} regions, {chunks} chunks",
|
||||
"cli.export.empty": "No chunks to export",
|
||||
"cli.import.importing": "Importing Minecraft save from \"{path}\"...",
|
||||
"cli.import.complete": "Import complete: {tiles} tiles, {dimensions} dimensions",
|
||||
"cli.import.no_regions": "No region files found in \"{path}\"",
|
||||
"cli.render.rendering": "Rendering world preview...",
|
||||
"cli.render.saved": "Preview saved to \"{path}\"",
|
||||
"cli.info.title": "World: {name}",
|
||||
"cli.info.seed": "Seed: {seed}",
|
||||
"cli.info.platform": "Platform: {platform}",
|
||||
"cli.info.tiles": "Tiles: {tiles}",
|
||||
"cli.info.dimensions": "Dimensions: {dimensions}",
|
||||
"cli.validate.ok": "World file is valid",
|
||||
"cli.validate.fail": "World file is invalid: {error}",
|
||||
"cli.error.not_found": "File not found: {path}",
|
||||
"cli.error.io": "I/O error: {error}",
|
||||
"cli.error.format": "Unsupported format: {format}",
|
||||
"cli.dry_run": "[DRY RUN] Would {action}",
|
||||
"cli.status.ok": "Done",
|
||||
"cli.status.error": "Error",
|
||||
"ops.smooth.applying": "Smoothing terrain...",
|
||||
"ops.erode.applying": "Eroding terrain...",
|
||||
"ops.raise.applying": "Raising terrain...",
|
||||
"ops.lower.applying": "Lowering terrain...",
|
||||
"ops.flatten.applying": "Flattening terrain..."
|
||||
}
|
||||
30
crates/i18n/src/translations/ru.json
Normal file
30
crates/i18n/src/translations/ru.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"cli.new.creating": "Создание нового мира...",
|
||||
"cli.new.created": "Мир \"{name}\" создан ({tiles} тайлов)",
|
||||
"cli.export.exporting": "Экспорт мира в \"{path}\"...",
|
||||
"cli.export.complete": "Экспорт завершён: {regions} регионов, {chunks} чанков",
|
||||
"cli.export.empty": "Нет чанков для экспорта",
|
||||
"cli.import.importing": "Импорт сохранения Minecraft из \"{path}\"...",
|
||||
"cli.import.complete": "Импорт завершён: {tiles} тайлов, {dimensions} измерений",
|
||||
"cli.import.no_regions": "Файлы регионов не найдены в \"{path}\"",
|
||||
"cli.render.rendering": "Рендер превью мира...",
|
||||
"cli.render.saved": "Превью сохранено в \"{path}\"",
|
||||
"cli.info.title": "Мир: {name}",
|
||||
"cli.info.seed": "Сид: {seed}",
|
||||
"cli.info.platform": "Платформа: {platform}",
|
||||
"cli.info.tiles": "Тайлов: {tiles}",
|
||||
"cli.info.dimensions": "Измерений: {dimensions}",
|
||||
"cli.validate.ok": "Файл мира корректен",
|
||||
"cli.validate.fail": "Файл мира повреждён: {error}",
|
||||
"cli.error.not_found": "Файл не найден: {path}",
|
||||
"cli.error.io": "Ошибка ввода-вывода: {error}",
|
||||
"cli.error.format": "Неподдерживаемый формат: {format}",
|
||||
"cli.dry_run": "[DRY RUN] Будет выполнено: {action}",
|
||||
"cli.status.ok": "Готово",
|
||||
"cli.status.error": "Ошибка",
|
||||
"ops.smooth.applying": "Сглаживание рельефа...",
|
||||
"ops.erode.applying": "Эрозия рельефа...",
|
||||
"ops.raise.applying": "Поднятие рельефа...",
|
||||
"ops.lower.applying": "Опускание рельефа...",
|
||||
"ops.flatten.applying": "Выравнивание рельефа..."
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue