1mod file_resource_loader;
4mod image_id;
5mod url_resource_loader;
6
7pub use file_resource_loader::FileResourceLoader;
8pub use image_id::ImageId;
9#[cfg(feature = "url-loader")]
10pub use url_resource_loader::UrlResourceLoader;
11use vizia_id::IdManager;
12use vizia_reactive::{Signal, SignalGet, SignalUpdate};
13
14use crate::context::ResourceContext;
15use crate::entity::Entity;
16use crate::prelude::IntoCssStr;
17use chrono::{DateTime, Utc};
19use fluent_bundle::types::{FluentNumber, FluentNumberOptions};
20use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
21use hashbrown::{HashMap, HashSet};
22use std::fmt;
23use unic_langid::LanguageIdentifier;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TranslationError {
28 InvalidFtl(String),
30 BundleError(String),
32}
33
34impl fmt::Display for TranslationError {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 TranslationError::InvalidFtl(msg) => write!(f, "Invalid FTL syntax: {}", msg),
38 TranslationError::BundleError(msg) => {
39 write!(f, "Failed to add to translation bundle: {}", msg)
40 }
41 }
42 }
43}
44
45impl std::error::Error for TranslationError {}
46
47fn fluent_number<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
48 let Some(first) = positional.first() else {
49 return FluentValue::Error;
50 };
51
52 let mut number = match first {
53 FluentValue::Number(num) => num.clone(),
54 FluentValue::String(value) => value
55 .parse::<FluentNumber>()
56 .unwrap_or_else(|_| FluentNumber::new(0.0, FluentNumberOptions::default())),
57 _ => return FluentValue::Error,
58 };
59
60 number.options.merge(named);
61 FluentValue::Number(number)
62}
63
64fn style_str(args: &FluentArgs, key: &str) -> Option<String> {
65 match args.get(key) {
66 Some(FluentValue::String(value)) => Some(value.to_string()),
67 _ => None,
68 }
69}
70
71fn datetime_format_pattern(args: &FluentArgs) -> String {
72 let weekday = match style_str(args, "weekday").as_deref() {
73 Some("long") => Some("%A"),
74 Some("short") => Some("%a"),
75 _ => None,
76 };
77
78 let month = match style_str(args, "month").as_deref() {
79 Some("long") => Some("%B"),
80 Some("short") => Some("%b"),
81 Some("2-digit") => Some("%m"),
82 Some("numeric") => Some("%-m"),
83 _ => None,
84 };
85
86 let day = match style_str(args, "day").as_deref() {
87 Some("2-digit") => Some("%d"),
88 Some("numeric") => Some("%-d"),
89 _ => None,
90 };
91
92 let year = match style_str(args, "year").as_deref() {
93 Some("2-digit") => Some("%y"),
94 Some("numeric") => Some("%Y"),
95 _ => None,
96 };
97
98 let hour = match style_str(args, "hour").as_deref() {
99 Some("2-digit") => Some("%H"),
100 Some("numeric") => Some("%-H"),
101 _ => None,
102 };
103
104 let minute = match style_str(args, "minute").as_deref() {
105 Some("2-digit") => Some("%M"),
106 Some("numeric") => Some("%-M"),
107 _ => None,
108 };
109
110 let mut date_parts = Vec::new();
111 if let Some(part) = weekday {
112 date_parts.push(part);
113 }
114 if let Some(part) = month {
115 date_parts.push(part);
116 }
117 if let Some(part) = day {
118 date_parts.push(part);
119 }
120 if let Some(part) = year {
121 date_parts.push(part);
122 }
123
124 let mut pattern = date_parts.join(" ");
125 if hour.is_some() || minute.is_some() {
126 if !pattern.is_empty() {
127 pattern.push(' ');
128 }
129 let mut time_parts = Vec::new();
130 if let Some(part) = hour {
131 time_parts.push(part);
132 }
133 if let Some(part) = minute {
134 time_parts.push(part);
135 }
136 pattern.push_str(&time_parts.join(":"));
137 }
138
139 if pattern.is_empty() { "%Y-%m-%d %H:%M:%S".to_string() } else { pattern }
140}
141
142fn fluent_datetime<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
143 let Some(first) = positional.first() else {
144 return FluentValue::Error;
145 };
146
147 let millis = match first {
148 FluentValue::Number(num) => num.value as i64,
149 FluentValue::String(value) => value.parse::<i64>().unwrap_or_default(),
150 _ => return FluentValue::Error,
151 };
152
153 let Some(dt) = DateTime::<Utc>::from_timestamp_millis(millis) else {
154 return FluentValue::Error;
155 };
156
157 let pattern = datetime_format_pattern(named);
158 FluentValue::String(dt.format(&pattern).to_string().into())
159}
160
161fn make_bundle(lang: LanguageIdentifier) -> FluentBundle<FluentResource> {
162 let mut bundle = FluentBundle::new(vec![lang]);
163
164 bundle.add_function("NUMBER", fluent_number).expect("Failed to register NUMBER function");
165 bundle.add_function("DATETIME", fluent_datetime).expect("Failed to register DATETIME function");
166
167 bundle
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub(crate) enum LocalizationIssue {
173 MissingMessage { key: String, requested_locale: String },
175 MissingAttribute { key: String, attribute: String, requested_locale: String },
177 FormatError { key: String, locale: String, details: String },
179}
180
181impl fmt::Display for LocalizationIssue {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 match self {
184 LocalizationIssue::MissingMessage { key, requested_locale } => {
185 write!(f, "Missing localized message '{}' for locale '{}'.", key, requested_locale)
186 }
187 LocalizationIssue::MissingAttribute { key, attribute, requested_locale } => write!(
188 f,
189 "Missing localized attribute '{}.{}' for locale '{}'.",
190 key, attribute, requested_locale
191 ),
192 LocalizationIssue::FormatError { key, locale, details } => {
193 write!(f, "Formatting error for key '{}' in locale '{}': {}", key, locale, details)
194 }
195 }
196 }
197}
198
199#[derive(Debug, Clone)]
201pub struct ImageRequest {
202 pub name: String,
204 pub path: String,
206 pub policy: ImageRetentionPolicy,
208}
209
210#[derive(Debug, Clone)]
212pub struct FontRequest {
213 pub path: String,
215}
216
217#[derive(Debug, Clone)]
219pub struct TranslationRequest {
220 pub lang: LanguageIdentifier,
222 pub path: String,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum LoadingStatus {
229 NotLoaded,
231 Loading,
233 Loaded,
235 Error,
237}
238
239#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
241pub enum ResourceLoadExecution {
242 #[default]
244 Auto,
245 Async,
247 Sync,
249}
250
251#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
253pub struct ResourceLoadOptions {
254 pub execution: ResourceLoadExecution,
256}
257
258impl ResourceLoadOptions {
259 pub const fn auto() -> Self {
261 Self { execution: ResourceLoadExecution::Auto }
262 }
263
264 pub const fn asynchronous() -> Self {
266 Self { execution: ResourceLoadExecution::Async }
267 }
268
269 pub const fn synchronous() -> Self {
271 Self { execution: ResourceLoadExecution::Sync }
272 }
273}
274
275#[non_exhaustive]
277#[derive(Debug, Clone)]
278pub enum ResourceRequest {
279 Image(ImageRequest),
281 Font(FontRequest),
283 Translation(TranslationRequest),
285}
286
287impl ResourceRequest {
288 pub fn path(&self) -> &str {
290 match self {
291 ResourceRequest::Image(req) => &req.path,
292 ResourceRequest::Font(req) => &req.path,
293 ResourceRequest::Translation(req) => &req.path,
294 }
295 }
296}
297
298#[derive(Debug, Clone)]
300pub struct QueuedResourceRequest {
301 pub request: ResourceRequest,
303 pub options: ResourceLoadOptions,
305}
306
307pub trait ResourceLoader: Send + Sync + 'static {
313 fn load(
318 &self,
319 request: ResourceRequest,
320 options: ResourceLoadOptions,
321 cx: &mut ResourceContext,
322 ) -> bool;
323
324 fn status(&self, _path: &str) -> LoadingStatus {
328 LoadingStatus::NotLoaded
329 }
330}
331
332pub(crate) enum ImageOrSvg {
333 Svg(skia_safe::svg::Dom),
334 Image(skia_safe::Image),
335}
336
337pub(crate) struct StoredImage {
338 pub image: ImageOrSvg,
339 pub retention_policy: ImageRetentionPolicy,
340 pub used: bool,
341 pub dirty: bool,
342 pub observers: HashSet<Entity>,
343}
344
345#[derive(Debug, Copy, Clone, PartialEq)]
347pub enum ImageRetentionPolicy {
348 Forever,
350 DropWhenUnusedForOneFrame,
352 DropWhenNoObservers,
354}
355
356#[doc(hidden)]
357#[derive(Default)]
358pub struct ResourceManager {
359 pub styles: Vec<Box<dyn IntoCssStr>>,
360
361 pub(crate) image_id_manager: IdManager<ImageId>,
362 pub(crate) images: HashMap<ImageId, StoredImage>,
363 pub(crate) image_ids: HashMap<String, ImageId>,
364 pub(crate) image_sources: HashMap<String, String>,
365
366 pub translations: HashMap<LanguageIdentifier, FluentBundle<FluentResource>>,
367
368 pub language: LanguageIdentifier,
369
370 pub(crate) resource_loaders: Vec<Box<dyn ResourceLoader>>,
371 pub(crate) pending_requests: Vec<QueuedResourceRequest>,
373 pub(crate) loading_status: HashMap<String, Signal<LoadingStatus>>,
376}
377
378impl ResourceManager {
379 pub fn new() -> Self {
380 let locale = sys_locale::get_locale().and_then(|l| l.parse().ok()).unwrap_or_default();
382
383 let mut image_id_manager = IdManager::new();
384
385 image_id_manager.create();
387
388 let images = HashMap::new();
389
390 #[cfg(feature = "url-loader")]
391 let resource_loaders: Vec<Box<dyn ResourceLoader>> =
393 vec![Box::new(FileResourceLoader), Box::new(UrlResourceLoader::default())];
394
395 #[cfg(not(feature = "url-loader"))]
396 let resource_loaders: Vec<Box<dyn ResourceLoader>> = vec![Box::new(FileResourceLoader)];
397
398 ResourceManager {
399 image_id_manager,
400 images,
401 image_ids: HashMap::new(),
402 image_sources: HashMap::new(),
403 styles: Vec::new(),
404
405 translations: HashMap::from([(
406 LanguageIdentifier::default(),
407 make_bundle(LanguageIdentifier::default()),
408 )]),
409
410 language: locale,
411 resource_loaders,
412 pending_requests: Vec::new(),
413 loading_status: HashMap::new(),
414 }
415 }
416
417 pub(crate) fn register_image_source(&mut self, name: String, path: String) {
419 self.image_sources.insert(name, path);
420 }
421
422 pub(crate) fn resolve_image_source<'a>(&'a self, name_or_path: &'a str) -> &'a str {
424 self.image_sources.get(name_or_path).map(String::as_str).unwrap_or(name_or_path)
425 }
426
427 pub(crate) fn report_localization_issue(&self, issue: LocalizationIssue) {
428 log::warn!("{}", issue);
430 }
431
432 pub fn renegotiate_language(&mut self) {
433 let available = self
434 .translations
435 .keys()
436 .filter(|&x| x != &LanguageIdentifier::default())
437 .collect::<Vec<_>>();
438 let locale = sys_locale::get_locale()
439 .and_then(|l| l.parse().ok())
440 .unwrap_or_else(|| available.first().copied().cloned().unwrap_or_default());
441 let default = LanguageIdentifier::default();
442 let default_ref = &default; let langs = fluent_langneg::negotiate::negotiate_languages(
444 &[locale],
445 &available,
446 Some(&default_ref),
447 fluent_langneg::NegotiationStrategy::Filtering,
448 );
449 self.language = (**langs.first().unwrap()).clone();
450 }
451
452 fn negotiate_translation_locale(&self, locale: &LanguageIdentifier) -> LanguageIdentifier {
453 if self.translations.contains_key(locale) {
454 return locale.clone();
455 }
456
457 let available = self
458 .translations
459 .keys()
460 .filter(|&lang| lang != &LanguageIdentifier::default())
461 .collect::<Vec<_>>();
462
463 if available.is_empty() {
464 return LanguageIdentifier::default();
465 }
466
467 let first_available = *available.first().expect("non-empty checked above");
471 let fallback =
472 if available.contains(&&self.language) { &self.language } else { first_available };
473 let langs = fluent_langneg::negotiate::negotiate_languages(
474 &[locale],
475 &available,
476 Some(&fallback),
477 fluent_langneg::NegotiationStrategy::Filtering,
478 );
479
480 langs.first().map(|lang| (**lang).clone()).unwrap_or_else(|| fallback.clone())
481 }
482
483 pub fn translation_locales(&self, locale: &LanguageIdentifier) -> Vec<LanguageIdentifier> {
484 let mut locales = Vec::new();
485
486 if self.translations.contains_key(locale) {
487 locales.push(locale.clone());
488 }
489
490 let negotiated = self.negotiate_translation_locale(locale);
491 if !locales.contains(&negotiated) {
492 locales.push(negotiated);
493 }
494
495 let default = LanguageIdentifier::default();
496 if !locales.contains(&default) {
497 locales.push(default);
498 }
499
500 locales
501 }
502
503 pub fn add_translation(
504 &mut self,
505 lang: LanguageIdentifier,
506 ftl: String,
507 ) -> Result<(), TranslationError> {
508 match fluent_bundle::FluentResource::try_new(ftl) {
509 Ok(res) => {
510 let bundle =
511 self.translations.entry(lang.clone()).or_insert_with(|| make_bundle(lang));
512 bundle.add_resource(res).map_err(|errors| {
513 let msg = format!("{:?}", errors);
514 TranslationError::BundleError(msg)
515 })?;
516 self.renegotiate_language();
517 Ok(())
518 }
519 Err((_, parse_errors)) => {
520 let msg =
521 parse_errors.iter().map(|e| format!("{:?}", e)).collect::<Vec<_>>().join("; ");
522 Err(TranslationError::InvalidFtl(msg))
523 }
524 }
525 }
526
527 pub fn current_translation(
528 &self,
529 locale: &LanguageIdentifier,
530 ) -> &FluentBundle<FluentResource> {
531 let locale = self.translation_locales(locale).into_iter().next().unwrap();
532 self.translations.get(&locale).unwrap()
533 }
534
535 pub fn mark_images_unused(&mut self) {
536 for (_, img) in self.images.iter_mut() {
537 img.used = false;
538 }
539 }
540
541 pub fn evict_unused_images(&mut self) {
542 let rem = self
543 .images
544 .iter()
545 .filter_map(|(id, img)| match img.retention_policy {
546 ImageRetentionPolicy::DropWhenUnusedForOneFrame => (!img.used).then_some(*id),
547
548 ImageRetentionPolicy::DropWhenNoObservers => {
549 img.observers.is_empty().then_some(*id)
550 }
551
552 ImageRetentionPolicy::Forever => None,
553 })
554 .collect::<Vec<_>>();
555
556 for id in rem {
557 self.images.remove(&id);
558
559 let removed_names = self
561 .image_ids
562 .iter()
563 .filter_map(|(name, img_id)| (*img_id == id).then_some(name.clone()))
564 .collect::<Vec<_>>();
565
566 let mut source_paths = Vec::new();
570 for name in removed_names {
571 self.image_ids.remove(&name);
572 let source_path = self.image_sources.remove(&name).unwrap_or(name);
573 source_paths.push(source_path);
574 }
575
576 for source_path in source_paths {
579 if !self.image_sources.values().any(|p| p == &source_path) {
580 self.loading_status.remove(&source_path);
581 }
582 }
583
584 self.image_id_manager.destroy(id);
585 }
586 }
587
588 pub fn resource_status(&self, path: &str) -> LoadingStatus {
590 if let Some(signal) = self.loading_status.get(path) {
592 return signal.get();
593 }
594
595 for loader in &self.resource_loaders {
597 let status = loader.status(path);
598 if status != LoadingStatus::NotLoaded {
599 return status;
600 }
601 }
602
603 LoadingStatus::NotLoaded
604 }
605
606 pub(crate) fn queue_resource_request(
608 &mut self,
609 request: ResourceRequest,
610 options: ResourceLoadOptions,
611 ) {
612 self.pending_requests.push(QueuedResourceRequest { request, options });
613 }
614
615 pub(crate) fn take_pending_resource_requests(&mut self) -> Vec<QueuedResourceRequest> {
617 std::mem::take(&mut self.pending_requests)
618 }
619
620 pub(crate) fn set_resource_status(&mut self, path: impl Into<String>, status: LoadingStatus) {
622 let path = path.into();
623 let signal = self.loading_status.entry(path).or_insert_with(|| Signal::new(status));
625 signal.set_if_changed(status);
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::entity::Entity;
634 use hashbrown::HashSet;
635
636 fn test_image() -> skia_safe::Image {
637 skia_safe::Image::from_encoded(unsafe {
638 skia_safe::Data::new_bytes(include_bytes!("../../resources/images/broken_image.png"))
639 })
640 .unwrap()
641 }
642
643 fn stored_image(
644 policy: ImageRetentionPolicy,
645 used: bool,
646 observers: HashSet<Entity>,
647 ) -> StoredImage {
648 StoredImage {
649 image: ImageOrSvg::Image(test_image()),
650 retention_policy: policy,
651 used,
652 dirty: false,
653 observers,
654 }
655 }
656
657 #[test]
658 fn add_translation_returns_error_for_invalid_ftl() {
659 let mut manager = ResourceManager::new();
660
661 let res = manager.add_translation("en-US".parse().unwrap(), "hello = { $name".to_string());
663
664 assert!(matches!(res, Err(TranslationError::InvalidFtl(_))));
665 }
666
667 #[test]
668 fn translation_locales_prefers_exact_then_default() {
669 let mut manager = ResourceManager::new();
670
671 manager.add_translation("fr".parse().unwrap(), "hello = Bonjour".to_string()).unwrap();
672
673 let locales = manager.translation_locales(&"fr".parse().unwrap());
674
675 assert_eq!(locales.first(), Some(&"fr".parse().unwrap()));
676 assert!(locales.contains(&LanguageIdentifier::default()));
677 }
678
679 #[test]
680 fn translation_locales_falls_back_to_default_when_no_locale_matches() {
681 let manager = ResourceManager::new();
682
683 let locales = manager.translation_locales(&"zz-ZZ".parse().unwrap());
684
685 assert_eq!(locales, vec![LanguageIdentifier::default()]);
686 }
687
688 #[test]
689 fn current_translation_falls_back_to_registered_bundle_when_requested_locale_missing() {
690 let mut manager = ResourceManager::new();
691
692 manager.add_translation("en-US".parse().unwrap(), "hello = Hello".to_string()).unwrap();
693
694 let bundle = manager.current_translation(&"zz-ZZ".parse().unwrap());
695
696 assert!(bundle.get_message("hello").is_some());
697 }
698
699 #[test]
700 fn current_translation_returns_registered_bundle_for_exact_match() {
701 let mut manager = ResourceManager::new();
702
703 manager.add_translation("fr".parse().unwrap(), "hello = Bonjour".to_string()).unwrap();
704
705 let bundle = manager.current_translation(&"fr".parse().unwrap());
706 let message = bundle.get_message("hello");
707
708 assert!(message.is_some());
709 }
710
711 #[test]
712 fn current_translation_returns_empty_default_when_no_translations_registered() {
713 let manager = ResourceManager::new();
714
715 let bundle = manager.current_translation(&"zz-ZZ".parse().unwrap());
718
719 assert!(bundle.get_message("hello").is_none());
720 }
721
722 #[test]
723 fn report_localization_issue_does_not_panic() {
724 let manager = ResourceManager::new();
725 manager.report_localization_issue(LocalizationIssue::MissingMessage {
726 key: "missing-key".to_string(),
727 requested_locale: "en-US".to_string(),
728 });
729 }
730
731 #[test]
732 fn evict_unused_images_keeps_used_one_frame_images() {
733 let mut manager = ResourceManager::new();
734
735 let used_id = manager.image_id_manager.create();
736 manager.images.insert(
737 used_id,
738 stored_image(ImageRetentionPolicy::DropWhenUnusedForOneFrame, true, HashSet::new()),
739 );
740
741 let unused_id = manager.image_id_manager.create();
742 manager.images.insert(
743 unused_id,
744 stored_image(ImageRetentionPolicy::DropWhenUnusedForOneFrame, false, HashSet::new()),
745 );
746
747 manager.evict_unused_images();
748
749 assert!(manager.images.contains_key(&used_id));
750 assert!(!manager.images.contains_key(&unused_id));
751 }
752
753 #[test]
754 fn evict_unused_images_clears_status_for_evicted_paths() {
755 let mut manager = ResourceManager::new();
756
757 let id = manager.image_id_manager.create();
758 let name = "my-image".to_string();
759 let path = "test://evicted-image".to_string();
760
761 manager.images.insert(
762 id,
763 stored_image(ImageRetentionPolicy::DropWhenNoObservers, false, HashSet::new()),
764 );
765 manager.image_ids.insert(name.clone(), id);
767 manager.image_sources.insert(name.clone(), path.clone());
768 manager.set_resource_status(path.clone(), LoadingStatus::Loaded);
769
770 manager.evict_unused_images();
771
772 assert!(!manager.image_ids.contains_key(&name));
773 assert!(!manager.image_sources.contains_key(&name));
774 assert_eq!(manager.resource_status(&path), LoadingStatus::NotLoaded);
775 }
776}