Skip to main content

vizia_core/resource/
mod.rs

1//! Resource management for fonts, themes, images, and translations.
2
3mod 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;
11// use crate::view::Canvas;
12use 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/// Error type for translation operations.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TranslationError {
22    /// FTL file syntax is invalid.
23    InvalidFtl(String),
24    /// Failed to add resource to translation bundle.
25    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/// Structured diagnostics emitted by localization while resolving messages.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub(crate) enum LocalizationIssue {
167    /// A message key was not found in any fallback bundle.
168    MissingMessage { key: String, requested_locale: String },
169    /// A message attribute was not found in any fallback bundle.
170    MissingAttribute { key: String, attribute: String, requested_locale: String },
171    /// Fluent formatting reported errors while resolving a message.
172    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/// An image should be stored in the resource manager.
207#[derive(Copy, Clone, PartialEq)]
208pub enum ImageRetentionPolicy {
209    ///  The image should live for the entire duration of the application.
210    Forever,
211    /// The image should be dropped when not used for one frame.
212    DropWhenUnusedForOneFrame,
213    /// The image should be dropped when no views are using the image.
214    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        // Get the system locale
236        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        // Disable this for now because reqwest pulls in too many dependencies.
241        // let default_image_loader: Option<Box<dyn Fn(&mut ResourceContext, &str)>> =
242        //     Some(Box::new(|cx: &mut ResourceContext, path: &str| {
243        //         if path.starts_with("https://") {
244        //             let path = path.to_string();
245        //             cx.spawn(move |cx| {
246        //                 let data = reqwest::blocking::get(&path).unwrap().bytes().unwrap();
247        //                 cx.load_image(
248        //                     path,
249        //                     image::load_from_memory_with_format(
250        //                         &data,
251        //                         image::guess_format(&data).unwrap(),
252        //                     )
253        //                     .unwrap(),
254        //                     ImageRetentionPolicy::DropWhenUnusedForOneFrame,
255        //                 )
256        //                 .unwrap();
257        //             });
258        //         } else {
259        //             // TODO: Try to load path from file
260        //         }
261        //     }));
262
263        let mut image_id_manager = IdManager::new();
264
265        // Create root id for broken image
266        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        // Localization issues are non-fatal and intended for diagnostics.
307        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; // ???
321        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        // Pick a fallback from the registered translations: prefer `self.language` if it
346        // is one of them, otherwise the first registered translation. `available` is
347        // non-empty here (checked above), so `available.first()` is always `Some`.
348        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        // Invalid FTL: unclosed placeable
451        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        // No `add_translation` call. The only entry in `translations` is the seeded empty
505        // default. A miss must not panic — it falls back to that default bundle.
506        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}