1use std::collections::HashMap;
7
8#[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}", 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#[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#[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 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
165fn build_english_translations() -> TranslationMap {
168 let mut map = TranslationMap::new();
169
170 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 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 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 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 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 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 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 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 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
301pub 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: &'a 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 key
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
420pub 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
569pub struct DateTimeFormatter;
572
573impl DateTimeFormatter {
574 fn epoch_to_parts(epoch_secs: i64) -> (i32, u32, u32, u32, u32, u32) {
575 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
716pub enum Align {
717 Left,
718 Center,
719 Right,
720}
721
722pub struct UnicodeUtils;
725
726impl UnicodeUtils {
727 pub fn char_width(c: char) -> usize {
730 let cp = c as u32;
731 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 | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F )
746 }
747
748 fn is_fullwidth_or_cjk(c: char) -> bool {
749 let cp = c as u32;
750 matches!(cp,
751 0x1100..=0x11FF | 0x2E80..=0x2FFF | 0x3000..=0x9FFF | 0xA000..=0xA4CF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE6F | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6 )
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 pub fn normalize_nfc(s: &str) -> String {
802 let mut result = String::with_capacity(s.len());
805 for ch in s.chars() {
806 result.push(Self::to_precomposed(ch));
808 }
809 result
810 }
811
812 fn to_precomposed(c: char) -> char {
813 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 {
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 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 if !current_line.is_empty() {
906 lines.push(current_line.clone());
907 current_line.clear();
908 current_width = 0;
909 }
910
911 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#[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#[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 if let Some(ref color) = self.fg {
1147 result.push_str(&color.ansi_fg());
1148 }
1149
1150 if let Some(ref color) = self.bg {
1152 result.push_str(&color.ansi_bg());
1153 }
1154
1155 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 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#[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#[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
1237pub 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 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 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 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 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 pub fn to_plain(spans: &[TextSpan]) -> String {
1369 spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join("")
1370 }
1371
1372 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
1392pub 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#[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 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 assert_eq!(result, "Level");
1486
1487 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 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); }
1561
1562 #[test]
1563 fn test_unicode_display_width() {
1564 assert_eq!(UnicodeUtils::display_width("hello"), 5);
1565 assert_eq!(UnicodeUtils::display_width("日本語"), 6); 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")); }
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}