1mod image_id;
4
5pub use image_id::ImageId;
6use vizia_id::{GenerationalId, IdManager};
7
8use crate::context::ResourceContext;
9use crate::entity::Entity;
10use crate::prelude::IntoCssStr;
11use chrono::{DateTime, Utc};
13use fluent_bundle::types::{FluentNumber, FluentNumberOptions};
14use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
15use hashbrown::{HashMap, HashSet};
16use std::fmt;
17use unic_langid::LanguageIdentifier;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TranslationError {
22 InvalidFtl(String),
24 BundleError(String),
26}
27
28impl fmt::Display for TranslationError {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 TranslationError::InvalidFtl(msg) => write!(f, "Invalid FTL syntax: {}", msg),
32 TranslationError::BundleError(msg) => {
33 write!(f, "Failed to add to translation bundle: {}", msg)
34 }
35 }
36 }
37}
38
39impl std::error::Error for TranslationError {}
40
41fn fluent_number<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
42 let Some(first) = positional.first() else {
43 return FluentValue::Error;
44 };
45
46 let mut number = match first {
47 FluentValue::Number(num) => num.clone(),
48 FluentValue::String(value) => value
49 .parse::<FluentNumber>()
50 .unwrap_or_else(|_| FluentNumber::new(0.0, FluentNumberOptions::default())),
51 _ => return FluentValue::Error,
52 };
53
54 number.options.merge(named);
55 FluentValue::Number(number)
56}
57
58fn style_str(args: &FluentArgs, key: &str) -> Option<String> {
59 match args.get(key) {
60 Some(FluentValue::String(value)) => Some(value.to_string()),
61 _ => None,
62 }
63}
64
65fn datetime_format_pattern(args: &FluentArgs) -> String {
66 let weekday = match style_str(args, "weekday").as_deref() {
67 Some("long") => Some("%A"),
68 Some("short") => Some("%a"),
69 _ => None,
70 };
71
72 let month = match style_str(args, "month").as_deref() {
73 Some("long") => Some("%B"),
74 Some("short") => Some("%b"),
75 Some("2-digit") => Some("%m"),
76 Some("numeric") => Some("%-m"),
77 _ => None,
78 };
79
80 let day = match style_str(args, "day").as_deref() {
81 Some("2-digit") => Some("%d"),
82 Some("numeric") => Some("%-d"),
83 _ => None,
84 };
85
86 let year = match style_str(args, "year").as_deref() {
87 Some("2-digit") => Some("%y"),
88 Some("numeric") => Some("%Y"),
89 _ => None,
90 };
91
92 let hour = match style_str(args, "hour").as_deref() {
93 Some("2-digit") => Some("%H"),
94 Some("numeric") => Some("%-H"),
95 _ => None,
96 };
97
98 let minute = match style_str(args, "minute").as_deref() {
99 Some("2-digit") => Some("%M"),
100 Some("numeric") => Some("%-M"),
101 _ => None,
102 };
103
104 let mut date_parts = Vec::new();
105 if let Some(part) = weekday {
106 date_parts.push(part);
107 }
108 if let Some(part) = month {
109 date_parts.push(part);
110 }
111 if let Some(part) = day {
112 date_parts.push(part);
113 }
114 if let Some(part) = year {
115 date_parts.push(part);
116 }
117
118 let mut pattern = date_parts.join(" ");
119 if hour.is_some() || minute.is_some() {
120 if !pattern.is_empty() {
121 pattern.push(' ');
122 }
123 let mut time_parts = Vec::new();
124 if let Some(part) = hour {
125 time_parts.push(part);
126 }
127 if let Some(part) = minute {
128 time_parts.push(part);
129 }
130 pattern.push_str(&time_parts.join(":"));
131 }
132
133 if pattern.is_empty() { "%Y-%m-%d %H:%M:%S".to_string() } else { pattern }
134}
135
136fn fluent_datetime<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
137 let Some(first) = positional.first() else {
138 return FluentValue::Error;
139 };
140
141 let millis = match first {
142 FluentValue::Number(num) => num.value as i64,
143 FluentValue::String(value) => value.parse::<i64>().unwrap_or_default(),
144 _ => return FluentValue::Error,
145 };
146
147 let Some(dt) = DateTime::<Utc>::from_timestamp_millis(millis) else {
148 return FluentValue::Error;
149 };
150
151 let pattern = datetime_format_pattern(named);
152 FluentValue::String(dt.format(&pattern).to_string().into())
153}
154
155fn make_bundle(lang: LanguageIdentifier) -> FluentBundle<FluentResource> {
156 let mut bundle = FluentBundle::new(vec![lang]);
157
158 bundle.add_function("NUMBER", fluent_number).expect("Failed to register NUMBER function");
159 bundle.add_function("DATETIME", fluent_datetime).expect("Failed to register DATETIME function");
160
161 bundle
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub(crate) enum LocalizationIssue {
167 MissingMessage { key: String, requested_locale: String },
169 MissingAttribute { key: String, attribute: String, requested_locale: String },
171 FormatError { key: String, locale: String, details: String },
173}
174
175impl fmt::Display for LocalizationIssue {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 LocalizationIssue::MissingMessage { key, requested_locale } => {
179 write!(f, "Missing localized message '{}' for locale '{}'.", key, requested_locale)
180 }
181 LocalizationIssue::MissingAttribute { key, attribute, requested_locale } => write!(
182 f,
183 "Missing localized attribute '{}.{}' for locale '{}'.",
184 key, attribute, requested_locale
185 ),
186 LocalizationIssue::FormatError { key, locale, details } => {
187 write!(f, "Formatting error for key '{}' in locale '{}': {}", key, locale, details)
188 }
189 }
190 }
191}
192
193pub(crate) enum ImageOrSvg {
194 Svg(skia_safe::svg::Dom),
195 Image(skia_safe::Image),
196}
197
198pub(crate) struct StoredImage {
199 pub image: ImageOrSvg,
200 pub retention_policy: ImageRetentionPolicy,
201 pub used: bool,
202 pub dirty: bool,
203 pub observers: HashSet<Entity>,
204}
205
206#[derive(Copy, Clone, PartialEq)]
208pub enum ImageRetentionPolicy {
209 Forever,
211 DropWhenUnusedForOneFrame,
213 DropWhenNoObservers,
215}
216
217#[doc(hidden)]
218#[derive(Default)]
219pub struct ResourceManager {
220 pub styles: Vec<Box<dyn IntoCssStr>>,
221
222 pub(crate) image_id_manager: IdManager<ImageId>,
223 pub(crate) images: HashMap<ImageId, StoredImage>,
224 pub(crate) image_ids: HashMap<String, ImageId>,
225
226 pub translations: HashMap<LanguageIdentifier, FluentBundle<FluentResource>>,
227
228 pub language: LanguageIdentifier,
229
230 pub image_loader: Option<Box<dyn Fn(&mut ResourceContext, &str)>>,
231}
232
233impl ResourceManager {
234 pub fn new() -> Self {
235 let locale = sys_locale::get_locale().and_then(|l| l.parse().ok()).unwrap_or_default();
237
238 let default_image_loader: Option<Box<dyn Fn(&mut ResourceContext, &str)>> = None;
239
240 let mut image_id_manager = IdManager::new();
264
265 image_id_manager.create();
267
268 let mut images = HashMap::new();
269
270 images.insert(
271 ImageId::root(),
272 StoredImage {
273 image: ImageOrSvg::Image(
274 skia_safe::Image::from_encoded(unsafe {
275 skia_safe::Data::new_bytes(include_bytes!(
276 "../../resources/images/broken_image.png"
277 ))
278 })
279 .unwrap(),
280 ),
281
282 retention_policy: ImageRetentionPolicy::Forever,
283 used: true,
284 dirty: false,
285 observers: HashSet::new(),
286 },
287 );
288
289 ResourceManager {
290 image_id_manager,
291 images,
292 image_ids: HashMap::new(),
293 styles: Vec::new(),
294
295 translations: HashMap::from([(
296 LanguageIdentifier::default(),
297 make_bundle(LanguageIdentifier::default()),
298 )]),
299
300 language: locale,
301 image_loader: default_image_loader,
302 }
303 }
304
305 pub(crate) fn report_localization_issue(&self, issue: LocalizationIssue) {
306 log::warn!("{}", issue);
308 }
309
310 pub fn renegotiate_language(&mut self) {
311 let available = self
312 .translations
313 .keys()
314 .filter(|&x| x != &LanguageIdentifier::default())
315 .collect::<Vec<_>>();
316 let locale = sys_locale::get_locale()
317 .and_then(|l| l.parse().ok())
318 .unwrap_or_else(|| available.first().copied().cloned().unwrap_or_default());
319 let default = LanguageIdentifier::default();
320 let default_ref = &default; let langs = fluent_langneg::negotiate::negotiate_languages(
322 &[locale],
323 &available,
324 Some(&default_ref),
325 fluent_langneg::NegotiationStrategy::Filtering,
326 );
327 self.language = (**langs.first().unwrap()).clone();
328 }
329
330 fn negotiate_translation_locale(&self, locale: &LanguageIdentifier) -> LanguageIdentifier {
331 if self.translations.contains_key(locale) {
332 return locale.clone();
333 }
334
335 let available = self
336 .translations
337 .keys()
338 .filter(|&lang| lang != &LanguageIdentifier::default())
339 .collect::<Vec<_>>();
340
341 if available.is_empty() {
342 return LanguageIdentifier::default();
343 }
344
345 let first_available = *available.first().expect("non-empty checked above");
349 let fallback =
350 if available.contains(&&self.language) { &self.language } else { first_available };
351 let langs = fluent_langneg::negotiate::negotiate_languages(
352 &[locale],
353 &available,
354 Some(&fallback),
355 fluent_langneg::NegotiationStrategy::Filtering,
356 );
357
358 langs.first().map(|lang| (**lang).clone()).unwrap_or_else(|| fallback.clone())
359 }
360
361 pub fn translation_locales(&self, locale: &LanguageIdentifier) -> Vec<LanguageIdentifier> {
362 let mut locales = Vec::new();
363
364 if self.translations.contains_key(locale) {
365 locales.push(locale.clone());
366 }
367
368 let negotiated = self.negotiate_translation_locale(locale);
369 if !locales.contains(&negotiated) {
370 locales.push(negotiated);
371 }
372
373 let default = LanguageIdentifier::default();
374 if !locales.contains(&default) {
375 locales.push(default);
376 }
377
378 locales
379 }
380
381 pub fn add_translation(
382 &mut self,
383 lang: LanguageIdentifier,
384 ftl: String,
385 ) -> Result<(), TranslationError> {
386 match fluent_bundle::FluentResource::try_new(ftl) {
387 Ok(res) => {
388 let bundle =
389 self.translations.entry(lang.clone()).or_insert_with(|| make_bundle(lang));
390 bundle.add_resource(res).map_err(|errors| {
391 let msg = format!("{:?}", errors);
392 TranslationError::BundleError(msg)
393 })?;
394 self.renegotiate_language();
395 Ok(())
396 }
397 Err((_, parse_errors)) => {
398 let msg =
399 parse_errors.iter().map(|e| format!("{:?}", e)).collect::<Vec<_>>().join("; ");
400 Err(TranslationError::InvalidFtl(msg))
401 }
402 }
403 }
404
405 pub fn current_translation(
406 &self,
407 locale: &LanguageIdentifier,
408 ) -> &FluentBundle<FluentResource> {
409 let locale = self.translation_locales(locale).into_iter().next().unwrap();
410 self.translations.get(&locale).unwrap()
411 }
412
413 pub fn mark_images_unused(&mut self) {
414 for (_, img) in self.images.iter_mut() {
415 img.used = false;
416 }
417 }
418
419 pub fn evict_unused_images(&mut self) {
420 let rem = self
421 .images
422 .iter()
423 .filter_map(|(id, img)| match img.retention_policy {
424 ImageRetentionPolicy::DropWhenUnusedForOneFrame => (img.used).then_some(*id),
425
426 ImageRetentionPolicy::DropWhenNoObservers => {
427 img.observers.is_empty().then_some(*id)
428 }
429
430 ImageRetentionPolicy::Forever => None,
431 })
432 .collect::<Vec<_>>();
433
434 for id in rem {
435 self.images.remove(&id);
436 self.image_ids.retain(|_, img| *img != id);
437 self.image_id_manager.destroy(id);
438 }
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn add_translation_returns_error_for_invalid_ftl() {
448 let mut manager = ResourceManager::new();
449
450 let res = manager.add_translation("en-US".parse().unwrap(), "hello = { $name".to_string());
452
453 assert!(matches!(res, Err(TranslationError::InvalidFtl(_))));
454 }
455
456 #[test]
457 fn translation_locales_prefers_exact_then_default() {
458 let mut manager = ResourceManager::new();
459
460 manager.add_translation("fr".parse().unwrap(), "hello = Bonjour".to_string()).unwrap();
461
462 let locales = manager.translation_locales(&"fr".parse().unwrap());
463
464 assert_eq!(locales.first(), Some(&"fr".parse().unwrap()));
465 assert!(locales.contains(&LanguageIdentifier::default()));
466 }
467
468 #[test]
469 fn translation_locales_falls_back_to_default_when_no_locale_matches() {
470 let manager = ResourceManager::new();
471
472 let locales = manager.translation_locales(&"zz-ZZ".parse().unwrap());
473
474 assert_eq!(locales, vec![LanguageIdentifier::default()]);
475 }
476
477 #[test]
478 fn current_translation_falls_back_to_registered_bundle_when_requested_locale_missing() {
479 let mut manager = ResourceManager::new();
480
481 manager.add_translation("en-US".parse().unwrap(), "hello = Hello".to_string()).unwrap();
482
483 let bundle = manager.current_translation(&"zz-ZZ".parse().unwrap());
484
485 assert!(bundle.get_message("hello").is_some());
486 }
487
488 #[test]
489 fn current_translation_returns_registered_bundle_for_exact_match() {
490 let mut manager = ResourceManager::new();
491
492 manager.add_translation("fr".parse().unwrap(), "hello = Bonjour".to_string()).unwrap();
493
494 let bundle = manager.current_translation(&"fr".parse().unwrap());
495 let message = bundle.get_message("hello");
496
497 assert!(message.is_some());
498 }
499
500 #[test]
501 fn current_translation_returns_empty_default_when_no_translations_registered() {
502 let manager = ResourceManager::new();
503
504 let bundle = manager.current_translation(&"zz-ZZ".parse().unwrap());
507
508 assert!(bundle.get_message("hello").is_none());
509 }
510
511 #[test]
512 fn report_localization_issue_does_not_panic() {
513 let manager = ResourceManager::new();
514 manager.report_localization_issue(LocalizationIssue::MissingMessage {
515 key: "missing-key".to_string(),
516 requested_locale: "en-US".to_string(),
517 });
518 }
519}