Skip to main content

proof_engine/game/
localization.rs

1//! Localization, i18n, number/date formatting, unicode utilities, and colored text.
2//!
3//! Provides Locale, L10n, NumberFormatter, DateTimeFormatter, UnicodeUtils,
4//! ColoredText, and MarkupParser with full implementations.
5
6use std::collections::HashMap;
7
8// ─── Locale ─────────────────────────────────────────────────────────────────────
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Locale {
12    EnUs,
13    FrFr,
14    DeDe,
15    JaJp,
16    ZhCn,
17    KoKr,
18    EsEs,
19    PtBr,
20    RuRu,
21    ArSa,
22}
23
24impl Locale {
25    pub fn code(&self) -> &str {
26        match self {
27            Locale::EnUs => "en_US",
28            Locale::FrFr => "fr_FR",
29            Locale::DeDe => "de_DE",
30            Locale::JaJp => "ja_JP",
31            Locale::ZhCn => "zh_CN",
32            Locale::KoKr => "ko_KR",
33            Locale::EsEs => "es_ES",
34            Locale::PtBr => "pt_BR",
35            Locale::RuRu => "ru_RU",
36            Locale::ArSa => "ar_SA",
37        }
38    }
39
40    pub fn name(&self) -> &str {
41        match self {
42            Locale::EnUs => "English (US)",
43            Locale::FrFr => "French (France)",
44            Locale::DeDe => "German (Germany)",
45            Locale::JaJp => "Japanese",
46            Locale::ZhCn => "Chinese (Simplified)",
47            Locale::KoKr => "Korean",
48            Locale::EsEs => "Spanish (Spain)",
49            Locale::PtBr => "Portuguese (Brazil)",
50            Locale::RuRu => "Russian",
51            Locale::ArSa => "Arabic (Saudi Arabia)",
52        }
53    }
54
55    pub fn is_rtl(&self) -> bool {
56        matches!(self, Locale::ArSa)
57    }
58
59    pub fn decimal_separator(&self) -> char {
60        match self {
61            Locale::EnUs | Locale::JaJp | Locale::ZhCn | Locale::KoKr => '.',
62            Locale::FrFr | Locale::RuRu => ',',
63            Locale::DeDe | Locale::EsEs | Locale::PtBr => ',',
64            Locale::ArSa => '.',
65        }
66    }
67
68    pub fn thousands_separator(&self) -> &str {
69        match self {
70            Locale::EnUs | Locale::JaJp | Locale::ZhCn | Locale::KoKr => ",",
71            Locale::FrFr | Locale::RuRu => "\u{202F}",  // narrow no-break space
72            Locale::DeDe => ".",
73            Locale::EsEs | Locale::PtBr => ".",
74            Locale::ArSa => ",",
75        }
76    }
77
78    pub fn all() -> &'static [Locale] {
79        &[
80            Locale::EnUs, Locale::FrFr, Locale::DeDe, Locale::JaJp,
81            Locale::ZhCn, Locale::KoKr, Locale::EsEs, Locale::PtBr,
82            Locale::RuRu, Locale::ArSa,
83        ]
84    }
85}
86
87// ─── Translation ─────────────────────────────────────────────────────────────────
88
89#[derive(Debug, Clone)]
90pub struct Translation {
91    pub key: String,
92    pub value: String,
93}
94
95impl Translation {
96    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
97        Self { key: key.into(), value: value.into() }
98    }
99}
100
101// ─── Translation Map ─────────────────────────────────────────────────────────────
102
103#[derive(Debug, Clone, Default)]
104pub struct TranslationMap {
105    entries: HashMap<String, String>,
106    plurals: HashMap<String, Vec<String>>,
107}
108
109impl TranslationMap {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
115        self.entries.insert(key.into(), value.into());
116    }
117
118    pub fn insert_plural(&mut self, key: impl Into<String>, forms: Vec<String>) {
119        self.plurals.insert(key.into(), forms);
120    }
121
122    pub fn get(&self, key: &str) -> Option<&str> {
123        self.entries.get(key).map(|s| s.as_str())
124    }
125
126    pub fn get_plural(&self, key: &str, form: usize) -> Option<&str> {
127        self.plurals.get(key)
128            .and_then(|forms| forms.get(form))
129            .map(|s| s.as_str())
130    }
131
132    pub fn contains(&self, key: &str) -> bool {
133        self.entries.contains_key(key)
134    }
135
136    pub fn len(&self) -> usize {
137        self.entries.len()
138    }
139
140    pub fn is_empty(&self) -> bool {
141        self.entries.is_empty()
142    }
143
144    /// Parse simple `key = "value"` format
145    pub fn parse_from_str(&mut self, data: &str) {
146        for line in data.lines() {
147            let line = line.trim();
148            if line.is_empty() || line.starts_with('#') {
149                continue;
150            }
151            if let Some(eq_pos) = line.find('=') {
152                let key = line[..eq_pos].trim().to_string();
153                let raw_val = line[eq_pos + 1..].trim();
154                let value = if raw_val.starts_with('"') && raw_val.ends_with('"') && raw_val.len() >= 2 {
155                    raw_val[1..raw_val.len() - 1].to_string()
156                } else {
157                    raw_val.to_string()
158                };
159                self.entries.insert(key, value);
160            }
161        }
162    }
163}
164
165// ─── Built-in English Translations ──────────────────────────────────────────────
166
167fn build_english_translations() -> TranslationMap {
168    let mut map = TranslationMap::new();
169
170    // Menu items
171    map.insert("menu.play", "Play");
172    map.insert("menu.continue", "Continue");
173    map.insert("menu.new_game", "New Game");
174    map.insert("menu.settings", "Settings");
175    map.insert("menu.credits", "Credits");
176    map.insert("menu.quit", "Quit");
177    map.insert("menu.resume", "Resume");
178    map.insert("menu.restart", "Restart");
179    map.insert("menu.main_menu", "Main Menu");
180    map.insert("menu.quit_desktop", "Quit to Desktop");
181    map.insert("menu.load_game", "Load Game");
182    map.insert("menu.save_game", "Save Game");
183    map.insert("menu.back", "Back");
184    map.insert("menu.confirm", "Confirm");
185    map.insert("menu.cancel", "Cancel");
186    map.insert("menu.yes", "Yes");
187    map.insert("menu.no", "No");
188    map.insert("menu.ok", "OK");
189    map.insert("menu.retry", "Retry");
190    map.insert("menu.delete", "Delete");
191
192    // Settings labels
193    map.insert("settings.title", "Settings");
194    map.insert("settings.graphics", "Graphics");
195    map.insert("settings.audio", "Audio");
196    map.insert("settings.controls", "Controls");
197    map.insert("settings.accessibility", "Accessibility");
198    map.insert("settings.language", "Language");
199    map.insert("settings.resolution", "Resolution");
200    map.insert("settings.fullscreen", "Fullscreen");
201    map.insert("settings.vsync", "V-Sync");
202    map.insert("settings.fps", "Target FPS");
203    map.insert("settings.quality", "Quality Preset");
204    map.insert("settings.master_vol", "Master Volume");
205    map.insert("settings.music_vol", "Music Volume");
206    map.insert("settings.sfx_vol", "SFX Volume");
207    map.insert("settings.voice_vol", "Voice Volume");
208    map.insert("settings.subtitles", "Subtitles");
209    map.insert("settings.colorblind", "Colorblind Mode");
210    map.insert("settings.high_contrast", "High Contrast");
211    map.insert("settings.reduce_motion", "Reduce Motion");
212    map.insert("settings.large_text", "Large Text");
213    map.insert("settings.screen_reader", "Screen Reader");
214
215    // Status effects
216    map.insert("status.burning", "Burning");
217    map.insert("status.frozen", "Frozen");
218    map.insert("status.poisoned", "Poisoned");
219    map.insert("status.stunned", "Stunned");
220    map.insert("status.slowed", "Slowed");
221    map.insert("status.hasted", "Hasted");
222    map.insert("status.shielded", "Shielded");
223    map.insert("status.cursed", "Cursed");
224    map.insert("status.blessed", "Blessed");
225    map.insert("status.silenced", "Silenced");
226    map.insert("status.confused", "Confused");
227    map.insert("status.invisible", "Invisible");
228
229    // Item rarity names
230    map.insert("rarity.common", "Common");
231    map.insert("rarity.uncommon", "Uncommon");
232    map.insert("rarity.rare", "Rare");
233    map.insert("rarity.epic", "Epic");
234    map.insert("rarity.legendary", "Legendary");
235    map.insert("rarity.mythic", "Mythic");
236    map.insert("rarity.unique", "Unique");
237
238    // Biome names
239    map.insert("biome.forest", "Verdant Forest");
240    map.insert("biome.desert", "Scorched Desert");
241    map.insert("biome.snow", "Frozen Tundra");
242    map.insert("biome.dungeon", "Dark Dungeon");
243    map.insert("biome.cave", "Crystal Cave");
244    map.insert("biome.volcano", "Volcanic Wastes");
245    map.insert("biome.ocean", "Abyssal Ocean");
246    map.insert("biome.sky", "Sky Citadel");
247    map.insert("biome.void", "The Void");
248
249    // Skill names
250    map.insert("skill.fireball", "Fireball");
251    map.insert("skill.lightning", "Chain Lightning");
252    map.insert("skill.heal", "Holy Light");
253    map.insert("skill.shield", "Iron Fortress");
254    map.insert("skill.dash", "Shadow Step");
255    map.insert("skill.arrow", "Piercing Arrow");
256    map.insert("skill.strike", "Power Strike");
257    map.insert("skill.blizzard", "Blizzard");
258    map.insert("skill.meteor", "Meteor Strike");
259    map.insert("skill.revive", "Resurrection");
260    map.insert("skill.stealth", "Vanish");
261    map.insert("skill.berserk", "Berserker Rage");
262
263    // Error messages
264    map.insert("error.save_failed", "Failed to save game data.");
265    map.insert("error.load_failed", "Failed to load save file.");
266    map.insert("error.no_save", "No save file found.");
267    map.insert("error.corrupt_save", "Save file is corrupted.");
268    map.insert("error.network", "Network connection lost.");
269    map.insert("error.unknown", "An unknown error occurred.");
270
271    // UI labels
272    map.insert("ui.level", "Level");
273    map.insert("ui.health", "Health");
274    map.insert("ui.mana", "Mana");
275    map.insert("ui.stamina", "Stamina");
276    map.insert("ui.gold", "Gold");
277    map.insert("ui.score", "Score");
278    map.insert("ui.combo", "Combo");
279    map.insert("ui.time", "Time");
280    map.insert("ui.wave", "Wave");
281    map.insert("ui.lives", "Lives");
282    map.insert("ui.kills", "Kills");
283    map.insert("ui.inventory", "Inventory");
284    map.insert("ui.equipment", "Equipment");
285    map.insert("ui.skills", "Skills");
286    map.insert("ui.map", "Map");
287    map.insert("ui.quest_log", "Quest Log");
288
289    // Plural forms for English
290    map.insert_plural("item", vec!["item".to_string(), "items".to_string()]);
291    map.insert_plural("enemy", vec!["enemy".to_string(), "enemies".to_string()]);
292    map.insert_plural("kill", vec!["kill".to_string(), "kills".to_string()]);
293    map.insert_plural("minute", vec!["minute".to_string(), "minutes".to_string()]);
294    map.insert_plural("hour", vec!["hour".to_string(), "hours".to_string()]);
295    map.insert_plural("day", vec!["day".to_string(), "days".to_string()]);
296    map.insert_plural("second", vec!["second".to_string(), "seconds".to_string()]);
297
298    map
299}
300
301// ─── L10n Context ────────────────────────────────────────────────────────────────
302
303pub struct L10n {
304    maps: HashMap<Locale, TranslationMap>,
305    current: Locale,
306    fallback: Locale,
307}
308
309impl L10n {
310    pub fn new() -> Self {
311        let mut l = Self {
312            maps: HashMap::new(),
313            current: Locale::EnUs,
314            fallback: Locale::EnUs,
315        };
316        l.maps.insert(Locale::EnUs, build_english_translations());
317        l
318    }
319
320    pub fn load(&mut self, locale: Locale, data: &str) {
321        let map = self.maps.entry(locale).or_default();
322        map.parse_from_str(data);
323    }
324
325    pub fn get<'a>(&'a self, key: &str) -> &'a str {
326        if let Some(map) = self.maps.get(&self.current) {
327            if let Some(val) = map.get(key) {
328                return val;
329            }
330        }
331        if let Some(map) = self.maps.get(&self.fallback) {
332            if let Some(val) = map.get(key) {
333                return val;
334            }
335        }
336        ""
337    }
338
339    pub fn fmt(&self, key: &str, args: &[(&str, &str)]) -> String {
340        let template = self.get(key);
341        let mut result = template.to_string();
342        for (name, value) in args {
343            let placeholder = format!("{{{}}}", name);
344            result = result.replace(&placeholder, value);
345        }
346        result
347    }
348
349    pub fn plural<'a>(&'a self, key: &str, n: i64) -> &'a str {
350        let form = self.plural_form(n);
351        if let Some(map) = self.maps.get(&self.current) {
352            if let Some(val) = map.get_plural(key, form) {
353                return val;
354            }
355        }
356        if let Some(map) = self.maps.get(&self.fallback) {
357            if let Some(val) = map.get_plural(key, form) {
358                return val;
359            }
360        }
361        ""
362    }
363
364    fn plural_form(&self, n: i64) -> usize {
365        match self.current {
366            Locale::EnUs | Locale::DeDe | Locale::EsEs | Locale::PtBr => {
367                if n == 1 { 0 } else { 1 }
368            }
369            Locale::FrFr => {
370                if n <= 1 { 0 } else { 1 }
371            }
372            Locale::RuRu => {
373                let n_mod10 = n.abs() % 10;
374                let n_mod100 = n.abs() % 100;
375                if n_mod10 == 1 && n_mod100 != 11 { 0 }
376                else if n_mod10 >= 2 && n_mod10 <= 4 && (n_mod100 < 10 || n_mod100 >= 20) { 1 }
377                else { 2 }
378            }
379            Locale::JaJp | Locale::ZhCn | Locale::KoKr => 0,
380            Locale::ArSa => {
381                match n {
382                    0 => 0,
383                    1 => 1,
384                    2 => 2,
385                    n if n % 100 >= 3 && n % 100 <= 10 => 3,
386                    n if n % 100 >= 11 => 4,
387                    _ => 5,
388                }
389            }
390        }
391    }
392
393    pub fn set_locale(&mut self, locale: Locale) {
394        self.current = locale;
395    }
396
397    pub fn current_locale(&self) -> Locale {
398        self.current
399    }
400
401    pub fn has_locale(&self, locale: Locale) -> bool {
402        self.maps.contains_key(&locale)
403    }
404
405    pub fn available_locales(&self) -> Vec<Locale> {
406        self.maps.keys().copied().collect()
407    }
408
409    pub fn key_count(&self, locale: Locale) -> usize {
410        self.maps.get(&locale).map(|m| m.len()).unwrap_or(0)
411    }
412}
413
414impl Default for L10n {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420// ─── Number Formatter ────────────────────────────────────────────────────────────
421
422pub struct NumberFormatter;
423
424impl NumberFormatter {
425    pub fn format_int(n: i64, locale: Locale) -> String {
426        let negative = n < 0;
427        let abs_n = n.unsigned_abs();
428        let digits = abs_n.to_string();
429        let sep = locale.thousands_separator();
430        let grouped = Self::group_digits(&digits, sep);
431        if negative { format!("-{}", grouped) } else { grouped }
432    }
433
434    fn group_digits(digits: &str, sep: &str) -> String {
435        if digits.len() <= 3 {
436            return digits.to_string();
437        }
438        let mut result = String::new();
439        let start = digits.len() % 3;
440        if start > 0 {
441            result.push_str(&digits[..start]);
442        }
443        let mut i = start;
444        while i < digits.len() {
445            if !result.is_empty() {
446                result.push_str(sep);
447            }
448            result.push_str(&digits[i..i + 3]);
449            i += 3;
450        }
451        result
452    }
453
454    pub fn format_float(f: f64, decimals: usize, locale: Locale) -> String {
455        let dec_sep = locale.decimal_separator();
456        let negative = f < 0.0;
457        let abs_f = f.abs();
458        let int_part = abs_f.floor() as i64;
459        let frac_part = abs_f - int_part as f64;
460        let frac_str = if decimals == 0 {
461            String::new()
462        } else {
463            let mult = 10f64.powi(decimals as i32);
464            let frac_digits = (frac_part * mult).round() as u64;
465            format!("{}{:0>width$}", dec_sep, frac_digits, width = decimals)
466        };
467        let int_str = Self::format_int(int_part, locale);
468        let sign = if negative { "-" } else { "" };
469        format!("{}{}{}", sign, int_str, frac_str)
470    }
471
472    pub fn format_percent(f: f32, locale: Locale) -> String {
473        format!("{}%", Self::format_float(f as f64 * 100.0, 1, locale))
474    }
475
476    pub fn format_currency(amount: i64, locale: Locale) -> String {
477        let (symbol, before) = match locale {
478            Locale::EnUs => ("$", true),
479            Locale::FrFr => ("€", false),
480            Locale::DeDe => ("€", false),
481            Locale::JaJp => ("¥", true),
482            Locale::ZhCn => ("¥", true),
483            Locale::KoKr => ("₩", true),
484            Locale::EsEs => ("€", false),
485            Locale::PtBr => ("R$", true),
486            Locale::RuRu => ("₽", false),
487            Locale::ArSa => ("﷼", false),
488        };
489        let num_str = Self::format_float(amount as f64 / 100.0, 2, locale);
490        if before {
491            format!("{}{}", symbol, num_str)
492        } else {
493            format!("{} {}", num_str, symbol)
494        }
495    }
496
497    pub fn format_duration(secs: f64, locale: Locale) -> String {
498        let total_secs = secs as u64;
499        let days = total_secs / 86400;
500        let hours = (total_secs % 86400) / 3600;
501        let minutes = (total_secs % 3600) / 60;
502        let seconds = total_secs % 60;
503
504        match locale {
505            Locale::JaJp => {
506                if days > 0 {
507                    format!("{}日{}時間", days, hours)
508                } else if hours > 0 {
509                    format!("{}時間{}分", hours, minutes)
510                } else if minutes > 0 {
511                    format!("{}分{}秒", minutes, seconds)
512                } else {
513                    format!("{}秒", seconds)
514                }
515            }
516            _ => {
517                if days > 0 {
518                    format!("{}d {}h", days, hours)
519                } else if hours > 0 {
520                    format!("{}h {}m", hours, minutes)
521                } else if minutes > 0 {
522                    format!("{}m {}s", minutes, seconds)
523                } else {
524                    format!("{}s", seconds)
525                }
526            }
527        }
528    }
529
530    pub fn format_large(n: i64, locale: Locale) -> String {
531        let dec_sep = locale.decimal_separator();
532        let abs_n = n.abs() as f64;
533        let sign = if n < 0 { "-" } else { "" };
534        if abs_n >= 1_000_000_000.0 {
535            let v = abs_n / 1_000_000_000.0;
536            format!("{}{:.1}B", sign, v).replace('.', &dec_sep.to_string())
537        } else if abs_n >= 1_000_000.0 {
538            let v = abs_n / 1_000_000.0;
539            format!("{}{:.1}M", sign, v).replace('.', &dec_sep.to_string())
540        } else if abs_n >= 1_000.0 {
541            let v = abs_n / 1_000.0;
542            format!("{}{:.1}K", sign, v).replace('.', &dec_sep.to_string())
543        } else {
544            Self::format_int(n, locale)
545        }
546    }
547
548    pub fn format_ordinal(n: u32, locale: Locale) -> String {
549        match locale {
550            Locale::EnUs => {
551                let suffix = match (n % 100, n % 10) {
552                    (11..=13, _) => "th",
553                    (_, 1) => "st",
554                    (_, 2) => "nd",
555                    (_, 3) => "rd",
556                    _ => "th",
557                };
558                format!("{}{}", n, suffix)
559            }
560            Locale::FrFr => {
561                let suffix = if n == 1 { "er" } else { "ème" };
562                format!("{}{}", n, suffix)
563            }
564            _ => format!("{}", n),
565        }
566    }
567}
568
569// ─── DateTime Formatter ──────────────────────────────────────────────────────────
570
571pub struct DateTimeFormatter;
572
573impl DateTimeFormatter {
574    fn epoch_to_parts(epoch_secs: i64) -> (i32, u32, u32, u32, u32, u32) {
575        // Simple implementation: compute year/month/day/hour/min/sec from epoch
576        const SECS_PER_MIN: i64 = 60;
577        const SECS_PER_HOUR: i64 = 3600;
578        const SECS_PER_DAY: i64 = 86400;
579
580        let mut days = epoch_secs / SECS_PER_DAY;
581        let time_in_day = epoch_secs % SECS_PER_DAY;
582        let hour = (time_in_day / SECS_PER_HOUR) as u32;
583        let minute = ((time_in_day % SECS_PER_HOUR) / SECS_PER_MIN) as u32;
584        let second = (time_in_day % SECS_PER_MIN) as u32;
585
586        // Compute year/month/day from days since 1970-01-01
587        let mut year = 1970i32;
588        loop {
589            let days_in_year = if Self::is_leap(year) { 366 } else { 365 };
590            if days < days_in_year {
591                break;
592            }
593            days -= days_in_year;
594            year += 1;
595        }
596        let months = [31u32, if Self::is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
597        let mut month = 1u32;
598        for &m_days in &months {
599            if days < m_days as i64 {
600                break;
601            }
602            days -= m_days as i64;
603            month += 1;
604        }
605        let day = (days + 1) as u32;
606
607        (year, month, day, hour, minute, second)
608    }
609
610    fn is_leap(year: i32) -> bool {
611        (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
612    }
613
614    pub fn format_date(epoch_secs: i64, locale: Locale) -> String {
615        let (year, month, day, _, _, _) = Self::epoch_to_parts(epoch_secs);
616        match locale {
617            Locale::EnUs => format!("{:02}/{:02}/{}", month, day, year),
618            Locale::DeDe | Locale::FrFr | Locale::EsEs | Locale::PtBr | Locale::RuRu => {
619                format!("{:02}.{:02}.{}", day, month, year)
620            }
621            Locale::JaJp | Locale::ZhCn | Locale::KoKr => {
622                format!("{}-{:02}-{:02}", year, month, day)
623            }
624            Locale::ArSa => format!("{:02}/{:02}/{}", day, month, year),
625        }
626    }
627
628    pub fn format_time(epoch_secs: i64, locale: Locale) -> String {
629        let (_, _, _, hour, minute, second) = Self::epoch_to_parts(epoch_secs);
630        match locale {
631            Locale::EnUs => {
632                let (h12, ampm) = if hour == 0 { (12, "AM") }
633                    else if hour < 12 { (hour, "AM") }
634                    else if hour == 12 { (12, "PM") }
635                    else { (hour - 12, "PM") };
636                format!("{}:{:02}:{:02} {}", h12, minute, second, ampm)
637            }
638            _ => format!("{:02}:{:02}:{:02}", hour, minute, second),
639        }
640    }
641
642    pub fn format_relative(epoch_secs: i64, now: i64, locale: Locale) -> String {
643        let diff = now - epoch_secs;
644        let abs_diff = diff.abs();
645
646        let (value, unit, past) = if abs_diff < 60 {
647            (abs_diff, "second", diff > 0)
648        } else if abs_diff < 3600 {
649            (abs_diff / 60, "minute", diff > 0)
650        } else if abs_diff < 86400 {
651            (abs_diff / 3600, "hour", diff > 0)
652        } else if abs_diff < 86400 * 30 {
653            (abs_diff / 86400, "day", diff > 0)
654        } else if abs_diff < 86400 * 365 {
655            (abs_diff / (86400 * 30), "month", diff > 0)
656        } else {
657            (abs_diff / (86400 * 365), "year", diff > 0)
658        };
659
660        match locale {
661            Locale::EnUs | Locale::EsEs => {
662                let plural_s = if value == 1 { "" } else { "s" };
663                if past {
664                    format!("{} {}{} ago", value, unit, plural_s)
665                } else {
666                    format!("in {} {}{}", value, unit, plural_s)
667                }
668            }
669            Locale::FrFr => {
670                let plural_s = if value == 1 { "" } else { "s" };
671                if past {
672                    format!("il y a {} {}{}", value, unit, plural_s)
673                } else {
674                    format!("dans {} {}{}", value, unit, plural_s)
675                }
676            }
677            Locale::DeDe => {
678                if past {
679                    format!("vor {} {}en", value, unit)
680                } else {
681                    format!("in {} {}en", value, unit)
682                }
683            }
684            Locale::JaJp => {
685                if past {
686                    format!("{}{}前", value, unit)
687                } else {
688                    format!("{}{}後", value, unit)
689                }
690            }
691            Locale::RuRu => {
692                if past {
693                    format!("{} {} назад", value, unit)
694                } else {
695                    format!("через {} {}", value, unit)
696                }
697            }
698            _ => {
699                if past {
700                    format!("{} {} ago", value, unit)
701                } else {
702                    format!("in {} {}", value, unit)
703                }
704            }
705        }
706    }
707
708    pub fn format_datetime(epoch_secs: i64, locale: Locale) -> String {
709        format!("{} {}", Self::format_date(epoch_secs, locale), Self::format_time(epoch_secs, locale))
710    }
711}
712
713// ─── Align ───────────────────────────────────────────────────────────────────────
714
715#[derive(Debug, Clone, Copy, PartialEq, Eq)]
716pub enum Align {
717    Left,
718    Center,
719    Right,
720}
721
722// ─── Unicode Utilities ───────────────────────────────────────────────────────────
723
724pub struct UnicodeUtils;
725
726impl UnicodeUtils {
727    /// Returns visual width of a single character.
728    /// CJK and full-width = 2, combining/zero-width = 0, else 1.
729    pub fn char_width(c: char) -> usize {
730        let cp = c as u32;
731        // Zero-width and combining characters
732        if cp == 0 { return 0; }
733        if Self::is_combining(c) { return 0; }
734        if Self::is_fullwidth_or_cjk(c) { return 2; }
735        1
736    }
737
738    fn is_combining(c: char) -> bool {
739        let cp = c as u32;
740        matches!(cp,
741            0x0300..=0x036F | // Combining Diacritical Marks
742            0x1DC0..=0x1DFF | // Combining Diacritical Marks Supplement
743            0x20D0..=0x20FF | // Combining Diacritical Marks for Symbols
744            0xFE20..=0xFE2F   // Combining Half Marks
745        )
746    }
747
748    fn is_fullwidth_or_cjk(c: char) -> bool {
749        let cp = c as u32;
750        matches!(cp,
751            0x1100..=0x11FF | // Hangul Jamo
752            0x2E80..=0x2FFF | // CJK Radicals
753            0x3000..=0x9FFF | // CJK Unified Ideographs (and punctuation, symbols, etc.)
754            0xA000..=0xA4CF | // Yi
755            0xAC00..=0xD7AF | // Hangul Syllables
756            0xF900..=0xFAFF | // CJK Compatibility Ideographs
757            0xFE10..=0xFE1F | // Vertical Forms
758            0xFE30..=0xFE6F | // CJK Compatibility Forms
759            0xFF00..=0xFF60 | // Fullwidth Forms
760            0xFFE0..=0xFFE6   // Fullwidth Signs
761        )
762    }
763
764    pub fn display_width(s: &str) -> usize {
765        s.chars().map(Self::char_width).sum()
766    }
767
768    pub fn truncate_display(s: &str, max_width: usize) -> &str {
769        let mut width = 0;
770        let mut byte_end = 0;
771        for (byte_idx, ch) in s.char_indices() {
772            let w = Self::char_width(ch);
773            if width + w > max_width {
774                return &s[..byte_end];
775            }
776            width += w;
777            byte_end = byte_idx + ch.len_utf8();
778        }
779        s
780    }
781
782    pub fn pad_display(s: &str, width: usize, align: Align) -> String {
783        let current_width = Self::display_width(s);
784        if current_width >= width {
785            return s.to_string();
786        }
787        let padding = width - current_width;
788        match align {
789            Align::Left => format!("{}{}", s, " ".repeat(padding)),
790            Align::Right => format!("{}{}", " ".repeat(padding), s),
791            Align::Center => {
792                let left_pad = padding / 2;
793                let right_pad = padding - left_pad;
794                format!("{}{}{}", " ".repeat(left_pad), s, " ".repeat(right_pad))
795            }
796        }
797    }
798
799    /// Simplified NFC normalization — decomposes and recomposes common diacritics.
800    /// Full NFC would require Unicode normalization tables; this handles common cases.
801    pub fn normalize_nfc(s: &str) -> String {
802        // For our purposes: pass-through but normalize ASCII and handle
803        // common precomposed forms. A production impl would use unicode-normalization crate.
804        let mut result = String::with_capacity(s.len());
805        for ch in s.chars() {
806            // Map common decomposed combinations back to precomposed
807            result.push(Self::to_precomposed(ch));
808        }
809        result
810    }
811
812    fn to_precomposed(c: char) -> char {
813        // Common precomposed mappings for Latin extended
814        match c as u32 {
815            0x0041 => 'A', 0x0042 => 'B', 0x0043 => 'C',
816            _ => c,
817        }
818    }
819
820    pub fn to_title_case(s: &str) -> String {
821        let mut result = String::with_capacity(s.len());
822        let mut capitalize_next = true;
823        for ch in s.chars() {
824            if ch == ' ' || ch == '\t' || ch == '\n' {
825                result.push(ch);
826                capitalize_next = true;
827            } else if capitalize_next {
828                for upper in ch.to_uppercase() {
829                    result.push(upper);
830                }
831                capitalize_next = false;
832            } else {
833                for lower in ch.to_lowercase() {
834                    result.push(lower);
835                }
836            }
837        }
838        result
839    }
840
841    pub fn to_snake_case(s: &str) -> String {
842        let mut result = String::with_capacity(s.len() + 4);
843        let mut prev_upper = false;
844        for (i, ch) in s.chars().enumerate() {
845            if ch == ' ' || ch == '-' {
846                result.push('_');
847                prev_upper = false;
848            } else if ch.is_uppercase() {
849                if i > 0 && !prev_upper {
850                    result.push('_');
851                }
852                for lower in ch.to_lowercase() {
853                    result.push(lower);
854                }
855                prev_upper = true;
856            } else {
857                result.push(ch);
858                prev_upper = false;
859            }
860        }
861        result
862    }
863
864    pub fn to_camel_case(s: &str) -> String {
865        let mut result = String::new();
866        let mut capitalize_next = false;
867        for (i, ch) in s.chars().enumerate() {
868            if ch == '_' || ch == '-' || ch == ' ' {
869                capitalize_next = true;
870            } else if capitalize_next {
871                for upper in ch.to_uppercase() {
872                    result.push(upper);
873                }
874                capitalize_next = false;
875            } else {
876                if i == 0 {
877                    for lower in ch.to_lowercase() {
878                        result.push(lower);
879                    }
880                } else {
881                    result.push(ch);
882                }
883            }
884        }
885        result
886    }
887
888    /// Word-wrap text to max_width, respecting CJK double-width characters.
889    pub fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
890        if max_width == 0 {
891            return vec![];
892        }
893        let mut lines = Vec::new();
894        for paragraph in text.split('\n') {
895            let mut current_line = String::new();
896            let mut current_width = 0usize;
897            let words: Vec<&str> = paragraph.split_whitespace().collect();
898
899            for (i, word) in words.iter().enumerate() {
900                let word_width = Self::display_width(word);
901                let space_needed = if current_line.is_empty() { 0 } else { 1 };
902
903                if current_width + space_needed + word_width > max_width {
904                    // Word doesn't fit on current line
905                    if !current_line.is_empty() {
906                        lines.push(current_line.clone());
907                        current_line.clear();
908                        current_width = 0;
909                    }
910
911                    // If the word itself is wider than max_width, split it
912                    if word_width > max_width {
913                        let mut char_buf = String::new();
914                        let mut char_width = 0;
915                        for ch in word.chars() {
916                            let cw = Self::char_width(ch);
917                            if char_width + cw > max_width {
918                                lines.push(char_buf.clone());
919                                char_buf.clear();
920                                char_width = 0;
921                            }
922                            char_buf.push(ch);
923                            char_width += cw;
924                        }
925                        if !char_buf.is_empty() {
926                            current_line = char_buf;
927                            current_width = char_width;
928                        }
929                    } else {
930                        current_line.push_str(word);
931                        current_width = word_width;
932                    }
933                } else {
934                    if i > 0 && !current_line.is_empty() {
935                        current_line.push(' ');
936                        current_width += 1;
937                    }
938                    current_line.push_str(word);
939                    current_width += word_width;
940                }
941            }
942
943            if !current_line.is_empty() {
944                lines.push(current_line);
945            } else if paragraph.is_empty() {
946                lines.push(String::new());
947            }
948        }
949        lines
950    }
951
952    pub fn repeat_char(ch: char, n: usize) -> String {
953        std::iter::repeat(ch).take(n).collect()
954    }
955
956    pub fn center_in_width(s: &str, width: usize) -> String {
957        Self::pad_display(s, width, Align::Center)
958    }
959
960    pub fn strip_ansi(s: &str) -> String {
961        let mut result = String::new();
962        let mut in_escape = false;
963        for ch in s.chars() {
964            if in_escape {
965                if ch == 'm' || ch == 'A' || ch == 'B' || ch == 'C' || ch == 'D' ||
966                   ch == 'H' || ch == 'J' || ch == 'K' {
967                    in_escape = false;
968                }
969            } else if ch == '\x1b' {
970                in_escape = true;
971            } else {
972                result.push(ch);
973            }
974        }
975        result
976    }
977}
978
979// ─── Terminal Color ──────────────────────────────────────────────────────────────
980
981#[derive(Debug, Clone, Copy, PartialEq)]
982pub enum TermColor {
983    Black,
984    Red,
985    Green,
986    Yellow,
987    Blue,
988    Magenta,
989    Cyan,
990    White,
991    BrightBlack,
992    BrightRed,
993    BrightGreen,
994    BrightYellow,
995    BrightBlue,
996    BrightMagenta,
997    BrightCyan,
998    BrightWhite,
999    Rgb(u8, u8, u8),
1000    Color256(u8),
1001}
1002
1003impl TermColor {
1004    pub fn ansi_fg(&self) -> String {
1005        match self {
1006            TermColor::Black => "\x1b[30m".to_string(),
1007            TermColor::Red => "\x1b[31m".to_string(),
1008            TermColor::Green => "\x1b[32m".to_string(),
1009            TermColor::Yellow => "\x1b[33m".to_string(),
1010            TermColor::Blue => "\x1b[34m".to_string(),
1011            TermColor::Magenta => "\x1b[35m".to_string(),
1012            TermColor::Cyan => "\x1b[36m".to_string(),
1013            TermColor::White => "\x1b[37m".to_string(),
1014            TermColor::BrightBlack => "\x1b[90m".to_string(),
1015            TermColor::BrightRed => "\x1b[91m".to_string(),
1016            TermColor::BrightGreen => "\x1b[92m".to_string(),
1017            TermColor::BrightYellow => "\x1b[93m".to_string(),
1018            TermColor::BrightBlue => "\x1b[94m".to_string(),
1019            TermColor::BrightMagenta => "\x1b[95m".to_string(),
1020            TermColor::BrightCyan => "\x1b[96m".to_string(),
1021            TermColor::BrightWhite => "\x1b[97m".to_string(),
1022            TermColor::Rgb(r, g, b) => format!("\x1b[38;2;{};{};{}m", r, g, b),
1023            TermColor::Color256(n) => format!("\x1b[38;5;{}m", n),
1024        }
1025    }
1026
1027    pub fn ansi_bg(&self) -> String {
1028        match self {
1029            TermColor::Black => "\x1b[40m".to_string(),
1030            TermColor::Red => "\x1b[41m".to_string(),
1031            TermColor::Green => "\x1b[42m".to_string(),
1032            TermColor::Yellow => "\x1b[43m".to_string(),
1033            TermColor::Blue => "\x1b[44m".to_string(),
1034            TermColor::Magenta => "\x1b[45m".to_string(),
1035            TermColor::Cyan => "\x1b[46m".to_string(),
1036            TermColor::White => "\x1b[47m".to_string(),
1037            TermColor::BrightBlack => "\x1b[100m".to_string(),
1038            TermColor::BrightRed => "\x1b[101m".to_string(),
1039            TermColor::BrightGreen => "\x1b[102m".to_string(),
1040            TermColor::BrightYellow => "\x1b[103m".to_string(),
1041            TermColor::BrightBlue => "\x1b[104m".to_string(),
1042            TermColor::BrightMagenta => "\x1b[105m".to_string(),
1043            TermColor::BrightCyan => "\x1b[106m".to_string(),
1044            TermColor::BrightWhite => "\x1b[107m".to_string(),
1045            TermColor::Rgb(r, g, b) => format!("\x1b[48;2;{};{};{}m", r, g, b),
1046            TermColor::Color256(n) => format!("\x1b[48;5;{}m", n),
1047        }
1048    }
1049}
1050
1051// ─── Colored Text ────────────────────────────────────────────────────────────────
1052
1053#[derive(Debug, Clone)]
1054pub struct ColoredText {
1055    text: String,
1056    fg: Option<TermColor>,
1057    bg: Option<TermColor>,
1058    bold: bool,
1059    italic: bool,
1060    underline: bool,
1061    blink: bool,
1062    strikethrough: bool,
1063    dim: bool,
1064}
1065
1066impl ColoredText {
1067    pub fn new(text: impl Into<String>) -> Self {
1068        Self {
1069            text: text.into(),
1070            fg: None,
1071            bg: None,
1072            bold: false,
1073            italic: false,
1074            underline: false,
1075            blink: false,
1076            strikethrough: false,
1077            dim: false,
1078        }
1079    }
1080
1081    pub fn fg(mut self, color: TermColor) -> Self {
1082        self.fg = Some(color);
1083        self
1084    }
1085
1086    pub fn bg(mut self, color: TermColor) -> Self {
1087        self.bg = Some(color);
1088        self
1089    }
1090
1091    pub fn bold(mut self) -> Self {
1092        self.bold = true;
1093        self
1094    }
1095
1096    pub fn italic(mut self) -> Self {
1097        self.italic = true;
1098        self
1099    }
1100
1101    pub fn underline(mut self) -> Self {
1102        self.underline = true;
1103        self
1104    }
1105
1106    pub fn blink(mut self) -> Self {
1107        self.blink = true;
1108        self
1109    }
1110
1111    pub fn strikethrough(mut self) -> Self {
1112        self.strikethrough = true;
1113        self
1114    }
1115
1116    pub fn dim(mut self) -> Self {
1117        self.dim = true;
1118        self
1119    }
1120
1121    pub fn text(&self) -> &str {
1122        &self.text
1123    }
1124
1125    pub fn render(&self) -> String {
1126        self.render_with_support(true)
1127    }
1128
1129    pub fn render_with_support(&self, supports_color: bool) -> String {
1130        if !supports_color {
1131            return self.text.clone();
1132        }
1133
1134        let mut codes = Vec::new();
1135
1136        if self.bold { codes.push("1".to_string()); }
1137        if self.dim { codes.push("2".to_string()); }
1138        if self.italic { codes.push("3".to_string()); }
1139        if self.underline { codes.push("4".to_string()); }
1140        if self.blink { codes.push("5".to_string()); }
1141        if self.strikethrough { codes.push("9".to_string()); }
1142
1143        let mut result = String::new();
1144
1145        // Apply fg color
1146        if let Some(ref color) = self.fg {
1147            result.push_str(&color.ansi_fg());
1148        }
1149
1150        // Apply bg color
1151        if let Some(ref color) = self.bg {
1152            result.push_str(&color.ansi_bg());
1153        }
1154
1155        // Apply attributes
1156        if !codes.is_empty() {
1157            result.push_str(&format!("\x1b[{}m", codes.join(";")));
1158        }
1159
1160        result.push_str(&self.text);
1161        result.push_str("\x1b[0m");
1162        result
1163    }
1164
1165    pub fn plain_len(&self) -> usize {
1166        UnicodeUtils::display_width(&self.text)
1167    }
1168
1169    /// Concatenate two ColoredText segments into a plain rendering
1170    pub fn concat(&self, other: &ColoredText) -> String {
1171        format!("{}{}", self.render(), other.render())
1172    }
1173}
1174
1175impl From<&str> for ColoredText {
1176    fn from(s: &str) -> Self {
1177        Self::new(s)
1178    }
1179}
1180
1181impl From<String> for ColoredText {
1182    fn from(s: String) -> Self {
1183        Self::new(s)
1184    }
1185}
1186
1187// ─── Text Style ─────────────────────────────────────────────────────────────────
1188
1189#[derive(Debug, Clone, Default)]
1190pub struct TextStyle {
1191    pub fg: Option<(u8, u8, u8)>,
1192    pub bg: Option<(u8, u8, u8)>,
1193    pub bold: bool,
1194    pub italic: bool,
1195    pub underline: bool,
1196    pub wave: Option<f32>,
1197    pub shake: Option<f32>,
1198    pub rainbow: bool,
1199}
1200
1201impl TextStyle {
1202    pub fn new() -> Self {
1203        Self::default()
1204    }
1205
1206    pub fn bold() -> Self {
1207        Self { bold: true, ..Default::default() }
1208    }
1209
1210    pub fn colored(r: u8, g: u8, b: u8) -> Self {
1211        Self { fg: Some((r, g, b)), ..Default::default() }
1212    }
1213
1214    pub fn with_wave(amp: f32) -> Self {
1215        Self { wave: Some(amp), ..Default::default() }
1216    }
1217}
1218
1219// ─── Text Span ──────────────────────────────────────────────────────────────────
1220
1221#[derive(Debug, Clone)]
1222pub struct TextSpan {
1223    pub text: String,
1224    pub style: TextStyle,
1225}
1226
1227impl TextSpan {
1228    pub fn new(text: impl Into<String>, style: TextStyle) -> Self {
1229        Self { text: text.into(), style }
1230    }
1231
1232    pub fn plain(text: impl Into<String>) -> Self {
1233        Self { text: text.into(), style: TextStyle::default() }
1234    }
1235}
1236
1237// ─── Markup Parser ───────────────────────────────────────────────────────────────
1238
1239/// Parses proof-engine rich text markup.
1240///
1241/// Supported tags:
1242/// - `[b]text[/b]` — bold
1243/// - `[i]text[/i]` — italic
1244/// - `[u]text[/u]` — underline
1245/// - `[color:rrggbb]text[/color]` — hex color
1246/// - `[wave:amplitude]text[/wave]` — wave animation
1247/// - `[shake:intensity]text[/shake]` — shake effect
1248/// - `[rainbow]text[/rainbow]` — rainbow color cycling
1249/// - `[bg:rrggbb]text[/bg]` — background color
1250pub struct MarkupParser;
1251
1252impl MarkupParser {
1253    pub fn parse(markup: &str) -> Vec<TextSpan> {
1254        let mut spans = Vec::new();
1255        let mut style_stack: Vec<TextStyle> = vec![TextStyle::default()];
1256        let mut current_text = String::new();
1257        let mut chars = markup.char_indices().peekable();
1258
1259        while let Some((_, ch)) = chars.next() {
1260            if ch == '[' {
1261                // Collect tag content
1262                let mut tag_buf = String::new();
1263                let mut closed = false;
1264                for (_, tc) in chars.by_ref() {
1265                    if tc == ']' {
1266                        closed = true;
1267                        break;
1268                    }
1269                    tag_buf.push(tc);
1270                }
1271
1272                if !closed {
1273                    current_text.push('[');
1274                    current_text.push_str(&tag_buf);
1275                    continue;
1276                }
1277
1278                let tag = tag_buf.trim();
1279
1280                if tag.starts_with('/') {
1281                    // Closing tag
1282                    if !current_text.is_empty() {
1283                        if let Some(style) = style_stack.last() {
1284                            spans.push(TextSpan::new(current_text.clone(), style.clone()));
1285                        }
1286                        current_text.clear();
1287                    }
1288                    if style_stack.len() > 1 {
1289                        style_stack.pop();
1290                    }
1291                } else {
1292                    // Opening tag — flush current text with current style
1293                    if !current_text.is_empty() {
1294                        if let Some(style) = style_stack.last() {
1295                            spans.push(TextSpan::new(current_text.clone(), style.clone()));
1296                        }
1297                        current_text.clear();
1298                    }
1299
1300                    // Build new style based on parent
1301                    let parent = style_stack.last().cloned().unwrap_or_default();
1302                    let new_style = Self::apply_tag(tag, parent);
1303                    style_stack.push(new_style);
1304                }
1305            } else {
1306                current_text.push(ch);
1307            }
1308        }
1309
1310        if !current_text.is_empty() {
1311            if let Some(style) = style_stack.last() {
1312                spans.push(TextSpan::new(current_text, style.clone()));
1313            }
1314        }
1315
1316        spans
1317    }
1318
1319    fn apply_tag(tag: &str, mut style: TextStyle) -> TextStyle {
1320        if tag == "b" || tag == "bold" {
1321            style.bold = true;
1322        } else if tag == "i" || tag == "italic" {
1323            style.italic = true;
1324        } else if tag == "u" || tag == "underline" {
1325            style.underline = true;
1326        } else if tag == "rainbow" {
1327            style.rainbow = true;
1328        } else if tag.starts_with("color:") {
1329            let hex = &tag[6..];
1330            if let Some(rgb) = Self::parse_hex_color(hex) {
1331                style.fg = Some(rgb);
1332            }
1333        } else if tag.starts_with("bg:") {
1334            let hex = &tag[3..];
1335            if let Some(rgb) = Self::parse_hex_color(hex) {
1336                style.bg = Some(rgb);
1337            }
1338        } else if tag.starts_with("wave:") {
1339            if let Ok(amp) = tag[5..].parse::<f32>() {
1340                style.wave = Some(amp);
1341            }
1342        } else if tag.starts_with("shake:") {
1343            if let Ok(intensity) = tag[6..].parse::<f32>() {
1344                style.shake = Some(intensity);
1345            }
1346        }
1347        style
1348    }
1349
1350    fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
1351        let hex = hex.trim_start_matches('#');
1352        if hex.len() == 6 {
1353            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1354            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1355            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1356            Some((r, g, b))
1357        } else if hex.len() == 3 {
1358            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
1359            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
1360            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
1361            Some((r, g, b))
1362        } else {
1363            None
1364        }
1365    }
1366
1367    /// Convert parsed spans back to plain text (no markup).
1368    pub fn to_plain(spans: &[TextSpan]) -> String {
1369        spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("")
1370    }
1371
1372    /// Convert parsed spans to ANSI-colored terminal output.
1373    pub fn to_ansi(spans: &[TextSpan]) -> String {
1374        let mut result = String::new();
1375        for span in spans {
1376            let mut ct = ColoredText::new(&span.text);
1377            if let Some((r, g, b)) = span.style.fg {
1378                ct = ct.fg(TermColor::Rgb(r, g, b));
1379            }
1380            if let Some((r, g, b)) = span.style.bg {
1381                ct = ct.bg(TermColor::Rgb(r, g, b));
1382            }
1383            if span.style.bold { ct = ct.bold(); }
1384            if span.style.italic { ct = ct.italic(); }
1385            if span.style.underline { ct = ct.underline(); }
1386            result.push_str(&ct.render());
1387        }
1388        result
1389    }
1390}
1391
1392// ─── Rich Text Builder ───────────────────────────────────────────────────────────
1393
1394pub struct RichTextBuilder {
1395    segments: Vec<(String, TextStyle)>,
1396    current_style: TextStyle,
1397}
1398
1399impl RichTextBuilder {
1400    pub fn new() -> Self {
1401        Self { segments: Vec::new(), current_style: TextStyle::default() }
1402    }
1403
1404    pub fn text(mut self, s: impl Into<String>) -> Self {
1405        self.segments.push((s.into(), self.current_style.clone()));
1406        self
1407    }
1408
1409    pub fn bold(mut self) -> Self {
1410        self.current_style.bold = true;
1411        self
1412    }
1413
1414    pub fn italic(mut self) -> Self {
1415        self.current_style.italic = true;
1416        self
1417    }
1418
1419    pub fn color(mut self, r: u8, g: u8, b: u8) -> Self {
1420        self.current_style.fg = Some((r, g, b));
1421        self
1422    }
1423
1424    pub fn reset_style(mut self) -> Self {
1425        self.current_style = TextStyle::default();
1426        self
1427    }
1428
1429    pub fn build(self) -> Vec<TextSpan> {
1430        self.segments.into_iter().map(|(text, style)| TextSpan { text, style }).collect()
1431    }
1432
1433    pub fn to_plain(&self) -> String {
1434        self.segments.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>().join("")
1435    }
1436}
1437
1438impl Default for RichTextBuilder {
1439    fn default() -> Self {
1440        Self::new()
1441    }
1442}
1443
1444// ─── Tests ──────────────────────────────────────────────────────────────────────
1445
1446#[cfg(test)]
1447mod tests {
1448    use super::*;
1449
1450    #[test]
1451    fn test_locale_codes() {
1452        assert_eq!(Locale::EnUs.code(), "en_US");
1453        assert_eq!(Locale::JaJp.code(), "ja_JP");
1454        assert!(Locale::ArSa.is_rtl());
1455        assert!(!Locale::EnUs.is_rtl());
1456    }
1457
1458    #[test]
1459    fn test_translation_map_parse() {
1460        let mut map = TranslationMap::new();
1461        map.parse_from_str(r#"
1462# This is a comment
1463greeting = "Hello, World!"
1464farewell = "Goodbye!"
1465"#);
1466        assert_eq!(map.get("greeting"), Some("Hello, World!"));
1467        assert_eq!(map.get("farewell"), Some("Goodbye!"));
1468        assert_eq!(map.get("missing"), None);
1469    }
1470
1471    #[test]
1472    fn test_l10n_get_fallback() {
1473        let l = L10n::new();
1474        assert_eq!(l.get("menu.play"), "Play");
1475        assert_eq!(l.get("menu.settings"), "Settings");
1476        // Missing key returns the key itself
1477        assert_eq!(l.get("nonexistent.key"), "nonexistent.key");
1478    }
1479
1480    #[test]
1481    fn test_l10n_fmt_substitution() {
1482        let l = L10n::new();
1483        let result = l.fmt("ui.level", &[]);
1484        // Key exists, no placeholders
1485        assert_eq!(result, "Level");
1486
1487        // Test with a custom format string loaded
1488        let mut l2 = L10n::new();
1489        l2.load(Locale::EnUs, "welcome = \"Hello, {name}!\"");
1490        let result = l2.fmt("welcome", &[("name", "Alice")]);
1491        assert_eq!(result, "Hello, Alice!");
1492    }
1493
1494    #[test]
1495    fn test_l10n_plural_english() {
1496        let l = L10n::new();
1497        assert_eq!(l.plural("item", 1), "item");
1498        assert_eq!(l.plural("item", 5), "items");
1499        assert_eq!(l.plural("enemy", 1), "enemy");
1500        assert_eq!(l.plural("enemy", 3), "enemies");
1501    }
1502
1503    #[test]
1504    fn test_number_formatter_int() {
1505        assert_eq!(NumberFormatter::format_int(1234567, Locale::EnUs), "1,234,567");
1506        assert_eq!(NumberFormatter::format_int(-999, Locale::EnUs), "-999");
1507        assert_eq!(NumberFormatter::format_int(1000, Locale::DeDe), "1.000");
1508        assert_eq!(NumberFormatter::format_int(0, Locale::EnUs), "0");
1509    }
1510
1511    #[test]
1512    fn test_number_formatter_float() {
1513        let result = NumberFormatter::format_float(1234.567, 2, Locale::EnUs);
1514        assert_eq!(result, "1,234.57");
1515        let result_de = NumberFormatter::format_float(1234.5, 1, Locale::DeDe);
1516        assert_eq!(result_de, "1.234,5");
1517    }
1518
1519    #[test]
1520    fn test_number_formatter_large() {
1521        assert_eq!(NumberFormatter::format_large(1500, Locale::EnUs), "1.5K");
1522        assert_eq!(NumberFormatter::format_large(2_300_000, Locale::EnUs), "2.3M");
1523        assert_eq!(NumberFormatter::format_large(4_100_000_000, Locale::EnUs), "4.1B");
1524        assert_eq!(NumberFormatter::format_large(500, Locale::EnUs), "500");
1525    }
1526
1527    #[test]
1528    fn test_number_formatter_duration() {
1529        let d = NumberFormatter::format_duration(7200.0, Locale::EnUs);
1530        assert_eq!(d, "2h 0m");
1531        let d2 = NumberFormatter::format_duration(90.0, Locale::EnUs);
1532        assert_eq!(d2, "1m 30s");
1533        let d3 = NumberFormatter::format_duration(45.0, Locale::EnUs);
1534        assert_eq!(d3, "45s");
1535    }
1536
1537    #[test]
1538    fn test_date_formatter() {
1539        // Unix epoch = 1970-01-01 00:00:00 UTC
1540        let date = DateTimeFormatter::format_date(0, Locale::EnUs);
1541        assert_eq!(date, "01/01/1970");
1542        let date_de = DateTimeFormatter::format_date(0, Locale::DeDe);
1543        assert_eq!(date_de, "01.01.1970");
1544    }
1545
1546    #[test]
1547    fn test_relative_time() {
1548        let rel = DateTimeFormatter::format_relative(1000, 1090, Locale::EnUs);
1549        assert_eq!(rel, "1 minute ago");
1550        let rel2 = DateTimeFormatter::format_relative(0, 7200, Locale::EnUs);
1551        assert_eq!(rel2, "2 hours ago");
1552    }
1553
1554    #[test]
1555    fn test_unicode_char_width() {
1556        assert_eq!(UnicodeUtils::char_width('A'), 1);
1557        assert_eq!(UnicodeUtils::char_width('中'), 2);
1558        assert_eq!(UnicodeUtils::char_width('한'), 2);
1559        assert_eq!(UnicodeUtils::char_width('\u{0300}'), 0); // combining grave
1560    }
1561
1562    #[test]
1563    fn test_unicode_display_width() {
1564        assert_eq!(UnicodeUtils::display_width("hello"), 5);
1565        assert_eq!(UnicodeUtils::display_width("日本語"), 6); // 3 CJK chars = 6
1566        assert_eq!(UnicodeUtils::display_width("A日"), 3);
1567    }
1568
1569    #[test]
1570    fn test_unicode_pad() {
1571        let padded = UnicodeUtils::pad_display("hi", 10, Align::Right);
1572        assert_eq!(padded.len(), 10);
1573        assert!(padded.starts_with("        "));
1574    }
1575
1576    #[test]
1577    fn test_word_wrap() {
1578        let lines = UnicodeUtils::word_wrap("The quick brown fox jumps over the lazy dog", 20);
1579        for line in &lines {
1580            assert!(UnicodeUtils::display_width(line) <= 20, "Line too wide: {:?}", line);
1581        }
1582    }
1583
1584    #[test]
1585    fn test_snake_case() {
1586        assert_eq!(UnicodeUtils::to_snake_case("CamelCase"), "camel_case");
1587        assert_eq!(UnicodeUtils::to_snake_case("hello world"), "hello_world");
1588        assert_eq!(UnicodeUtils::to_snake_case("HTML"), "h_t_m_l");
1589    }
1590
1591    #[test]
1592    fn test_title_case() {
1593        assert_eq!(UnicodeUtils::to_title_case("hello world"), "Hello World");
1594        assert_eq!(UnicodeUtils::to_title_case("the quick brown fox"), "The Quick Brown Fox");
1595    }
1596
1597    #[test]
1598    fn test_colored_text() {
1599        let ct = ColoredText::new("Hello").fg(TermColor::Red).bold();
1600        let rendered = ct.render();
1601        assert!(rendered.contains("Hello"));
1602        assert!(rendered.contains("\x1b["));
1603        assert!(rendered.contains("\x1b[0m")); // reset at end
1604    }
1605
1606    #[test]
1607    fn test_markup_parser() {
1608        let spans = MarkupParser::parse("[b]bold[/b] and [color:ff0000]red[/color] text");
1609        assert!(spans.len() >= 3);
1610        assert!(spans[0].style.bold);
1611        let red_span = spans.iter().find(|s| s.style.fg == Some((255, 0, 0)));
1612        assert!(red_span.is_some());
1613        let plain = MarkupParser::to_plain(&spans);
1614        assert_eq!(plain, "bold and red text");
1615    }
1616
1617    #[test]
1618    fn test_markup_wave() {
1619        let spans = MarkupParser::parse("[wave:0.5]animated[/wave]");
1620        assert_eq!(spans.len(), 1);
1621        assert_eq!(spans[0].style.wave, Some(0.5));
1622    }
1623}