zng_ext_l10n/
lib.rs

1#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo.png")]
3//!
4//! Localization service, [`l10n!`] and helpers.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12use std::{collections::HashMap, path::PathBuf, sync::Arc};
13
14use semver::Version;
15use zng_app::{
16    AppExtension,
17    event::{Command, CommandMetaVar, EVENTS_L10N},
18    update::EventUpdate,
19    view_process::{VIEW_PROCESS_INITED_EVENT, raw_events::RAW_LOCALE_CONFIG_CHANGED_EVENT},
20};
21use zng_layout::context::LayoutDirection;
22use zng_task as task;
23
24use zng_txt::Txt;
25use zng_var::{ArcEq, ArcVar, BoxedVar, ReadOnlyArcVar, Var, types::ArcCowVar};
26
27#[doc(hidden)]
28pub use zng_ext_l10n_proc_macros::lang as __lang;
29
30#[doc(hidden)]
31pub use zng_ext_l10n_proc_macros::l10n as __l10n;
32
33#[doc(hidden)]
34pub use unic_langid;
35
36mod types;
37pub use types::*;
38
39mod service;
40use service::L10N_SV;
41
42mod sources;
43pub use sources::*;
44
45/// Localization service.
46pub struct L10N;
47
48/// Application extension that provides localization.
49///
50/// # Services
51///
52/// Services this extension provides.
53///
54/// * [`L10N`]
55#[derive(Default)]
56pub struct L10nManager {}
57impl AppExtension for L10nManager {
58    fn init(&mut self) {
59        EVENTS_L10N.init_l10n(|file, cmd, attr, txt| {
60            L10N.bind_command_meta(
61                LangFilePath {
62                    pkg_name: file[0].into(),
63                    pkg_version: file[1].parse().unwrap_or_else(|e| {
64                        tracing::error!("invalid package version from command localization, {e}");
65                        Version::new(0, 0, 0)
66                    }),
67                    file: file[2].into(),
68                },
69                cmd,
70                attr,
71                txt,
72            );
73        });
74    }
75
76    fn event_preview(&mut self, update: &mut EventUpdate) {
77        if let Some(u) = RAW_LOCALE_CONFIG_CHANGED_EVENT
78            .on(update)
79            .map(|args| &args.config)
80            .or_else(|| VIEW_PROCESS_INITED_EVENT.on(update).map(|args| &args.locale_config))
81        {
82            L10N_SV.read().set_sys_langs(u);
83        }
84    }
85}
86
87///<span data-del-macro-root></span> Gets a variable that localizes and formats the text in a widget context.
88///
89/// # Syntax
90///
91/// Macro expects a message key string literal a *message template* string literal that is also used
92/// as fallback, followed by optional named format arguments `arg = <arg>,..`.
93///
94/// The message string syntax is the [Fluent Project] syntax, interpolations in the form of `"{$var}"` are resolved to a local `$var`.
95///
96/// ```
97/// # use zng_ext_l10n::*;
98/// # use zng_var::*;
99/// # let _scope = zng_app::APP.minimal();
100/// let name = var("World");
101/// let msg = l10n!("file/id.attribute", "Hello {$name}!");
102/// ```
103///
104/// ## Key
105///
106/// This message key can be just a Fluent identifier, `"id"`, a Fluent attribute identifier can be added `"id.attr"`, and finally
107/// a file name can be added `"file/id"`. The key syntax is validated at compile time.
108///
109/// ### Id
110///
111/// The only required part of a key is the ID, it must contain at least one character, it must start with an ASCII letter
112/// and can be followed by any ASCII alphanumeric, _ and -, `[a-zA-Z][a-zA-Z0-9_-]*`.
113///
114/// ### Attribute
115///
116/// An attribute identifier can be suffixed on the id, separated by a `.` followed by an identifier of the same pattern as the
117/// id, `.[a-zA-Z][a-zA-Z0-9_-]*`.
118///
119/// ### File
120///
121/// An optional file name can be prefixed on the id, separated by a `/`, it can be a single file name, no extension.
122///
123/// Using the default directory resolver the key `"file/id.attr"` will search the id and attribute in the file `{dir}/{lang}/file.ftl`:
124///
125/// ```ftl
126/// id =
127///     .attr = message
128/// ```
129///
130/// And a key `"id.attr"` will be searched in the file `{dir}/{lang}/_.ftl`.
131///
132/// ### Package
133///
134/// The crate package name and version are also implicitly collected, when the message is requested from a different crate
135/// it is searched in `{dir}/{lang}/{pkg-name}/{pkg-version}/{file}.ftl`. Version matches any other version, the nearest is selected.
136///
137/// # Scrap Template
138///
139/// The `cargo zng l10n` tool can be used to collect all localizable text of Rust code files, it is a text based search that
140/// matches this macro name and the two first input literals, avoid renaming this macro to support scrapping, otherwise you will
141/// have to declare the template file manually.
142///
143/// The scrapper can also scrap comments, if the previous code line from a [`l10n!`] call is a comment starting with
144/// prefix `l10n-# ` the text the follows is collected, same for a comment in the same line of the [`l10n!`] call. Sections
145/// can be declared using `l10n-## `, all entries after a section comment are added to that section. Standalone notes can be added to
146/// the top of the template file from anywhere using `l10n-{file_pattern}-### `, file pattern can be omitted, `l10n-### ` is equivalent
147/// to `l10n--### ` that matches the localization template used when no file is specified.
148///
149/// ```
150/// # use zng_ext_l10n::*;
151/// # use zng_var::*;
152/// # let _scope = zng_app::APP.minimal();
153/// #
154/// // l10n-### Standalone Note
155///
156/// // l10n-# Comment for `id`.
157/// let msg = l10n!("id", "id message");
158///
159/// // l10n-# Comment for `id.attr`.
160/// let msg = l10n!("id.attr", "attr message");
161///
162/// // l10n-## Section
163///
164/// let msg = l10n!("other", "other message"); // l10n-# Comment for `other`.
165/// ```
166///
167/// The example above is scrapped to a `template.ftl` file:
168///
169/// ```ftl
170/// ### Standalone Note
171///
172/// # Comment for `id`.
173/// #
174/// # attr:
175/// #     Comment for `id.attr`.
176/// id = id message
177///     .attr = attr message
178///
179/// ## Section
180///
181/// # Commend for `other`.
182/// other = other message
183/// ```
184///
185/// You can install the scraper tool using cargo:
186///
187/// ```console
188/// cargo install cargo-zng
189/// ```
190///
191/// [Fluent Project]: https://projectfluent.org/fluent/guide/
192#[macro_export]
193macro_rules! l10n {
194    ($message_id:tt, $message:tt $(,)?) => {
195        $crate::__l10n! {
196            l10n_path { $crate }
197            message_id { $message_id }
198            message { $message }
199        }
200    };
201    ($message_id:tt, $message:tt, $($arg:ident = $arg_expr:expr),* $(,)?) => {
202        {
203            $(
204                let $arg = $arg_expr;
205            )*
206            $crate::__l10n! {
207                l10n_path { $crate }
208                message_id { $message_id }
209                message { $message }
210            }
211        }
212    };
213    ($($error:tt)*) => {
214        std::compile_error!(r#"expected ("id", "message") or ("id", "msg {$arg}", arg=expr)"#)
215    }
216}
217
218impl L10N {
219    /// Change the localization resources to `source`.
220    ///
221    /// All active variables and handles will be updated to use the new source.
222    pub fn load(&self, source: impl L10nSource) {
223        L10N_SV.write().load(source);
224    }
225
226    /// Start watching the `dir` for `dir/{lang}/*.ftl` and `dir/{lang}/deps/*/*/*.ftl` files.
227    ///
228    /// The [`available_langs`] variable maintains an up-to-date list of locale files found, the files
229    /// are only loaded when needed, and also are watched to update automatically.
230    ///
231    /// [`available_langs`]: Self::available_langs
232    pub fn load_dir(&self, dir: impl Into<PathBuf>) {
233        self.load(L10nDir::open(dir))
234    }
235
236    /// Load localization resources from a `.tar` or `.tar.gz` container.
237    ///
238    /// The expected container layout is `root_dir/{lang}/{file}.ftl` app files and `root_dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`
239    /// for dependencies, same as [`load_dir`], `root_dir` can have any name.
240    ///
241    /// The data can be embedded using [`include_bytes!`] or loaded into a `Vec<u8>` and must be in the `.tar` or `.tar.gz` format.
242    ///
243    /// [`load_dir`]: L10N::load_dir
244    #[cfg(feature = "tar")]
245    pub fn load_tar(&self, data: impl Into<L10nTarData>) {
246        self.load(L10nTar::load(data))
247    }
248
249    /// Available localization files.
250    ///
251    /// The value maps lang to one or more files, the files can be from the project `dir/{lang}/{file}.ftl` or from dependencies
252    /// `dir/{lang}/deps/{pkg-name/{pkg-version}/{file}.ftl`.
253    ///
254    /// Note that this map will include any file in the source dir that has a name that is a valid [`lang!`],
255    /// that includes the `template.ftl` file and test pseudo-locales such as `qps-ploc.ftl`.
256    pub fn available_langs(&self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
257        L10N_SV.write().available_langs()
258    }
259
260    /// Status of the [`available_langs`] list.
261    ///
262    /// This will be `NotAvailable` before the first call to [`load_dir`], then it changes to `Loading`, then
263    /// `Loaded` or `Error`.
264    ///
265    /// Note that this is the status of the resource list, not of each individual resource, you
266    /// can use [`LangResource::status`] for that.
267    ///
268    /// [`available_langs`]: Self::available_langs
269    /// [`load_dir`]: Self::load_dir
270    pub fn available_langs_status(&self) -> BoxedVar<LangResourceStatus> {
271        L10N_SV.write().available_langs_status()
272    }
273
274    /// Waits until [`available_langs_status`] is not `Loading`.
275    ///
276    /// [`available_langs_status`]: Self::available_langs_status
277    pub async fn wait_available_langs(&self) {
278        // wait potential `load_dir` start.
279        task::yield_now().await;
280
281        let status = self.available_langs_status();
282        while matches!(status.get(), LangResourceStatus::Loading) {
283            status.wait_update().await;
284        }
285    }
286
287    /// Gets a read-write variable that sets the preferred languages for the app.
288    /// Lang not available are ignored until they become available, the first language in the
289    /// vec is the most preferred.
290    ///
291    /// The value is the same as [`sys_lang`], if set the variable disconnects from system lang.
292    ///
293    /// Note that the [`LANG_VAR`] is used in message requests, the default value of that
294    /// context variable is this one.
295    ///
296    /// [`sys_lang`]: Self::sys_lang
297    pub fn app_lang(&self) -> ArcCowVar<Langs, ArcVar<Langs>> {
298        L10N_SV.read().app_lang()
299    }
300
301    /// Gets a read-only variable that is the current system language.
302    ///
303    /// The variable will update when the view-process notifies that the config has changed. Is
304    /// empty if the system locale cannot be retrieved.
305    pub fn sys_lang(&self) -> ReadOnlyArcVar<Langs> {
306        L10N_SV.read().sys_lang()
307    }
308
309    /// Gets a read-only variable that is a localized message in the localization context
310    /// where the variable is first used. The variable will update when the contextual language changes.
311    ///
312    /// If the message has variable arguments they must be provided using [`L10nMessageBuilder::arg`], the
313    /// returned variable will also update when the arg variables update.
314    ///
315    /// Prefer using the [`l10n!`] macro instead of this method, the macro does compile time validation.
316    ///
317    /// # Params
318    ///
319    /// * `file`: Name of the resource file, in the default directory layout the file is searched at `dir/{lang}/{file}.ftl`, if
320    ///   empty the file is searched at `dir/{lang}/_.ftl`. Only a single file name is valid, no other path components allowed.
321    ///   Note that the file can also be a full [`LangFilePath`] that includes dependency package info. Those files are searched in
322    ///   `dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`.
323    /// * `id`: Message identifier inside the resource file.
324    /// * `attribute`: Attribute of the identifier, leave empty to not use an attribute.
325    /// * `fallback`: Message to use when a localized message cannot be found.
326    ///
327    /// The `id` and `attribute` is only valid if it starts with letter `[a-zA-Z]`, followed by any letters, digits, _ or - `[a-zA-Z0-9_-]*`.
328    ///
329    /// Panics if any parameter is invalid.
330    pub fn message(
331        &self,
332        file: impl Into<LangFilePath>,
333        id: impl Into<Txt>,
334        attribute: impl Into<Txt>,
335        fallback: impl Into<Txt>,
336    ) -> L10nMessageBuilder {
337        L10nMessageBuilder {
338            file: file.into(),
339            id: id.into(),
340            attribute: attribute.into(),
341            fallback: fallback.into(),
342            args: vec![],
343        }
344    }
345
346    /// Function called by `l10n!`.
347    #[doc(hidden)]
348    pub fn l10n_message(
349        &self,
350        pkg_name: &'static str,
351        pkg_version: &'static str,
352        file: &'static str,
353        id: &'static str,
354        attribute: &'static str,
355        fallback: &'static str,
356    ) -> L10nMessageBuilder {
357        self.message(
358            LangFilePath {
359                pkg_name: Txt::from_static(pkg_name),
360                pkg_version: pkg_version.parse().unwrap(),
361                file: Txt::from_static(file),
362            },
363            Txt::from_static(id),
364            Txt::from_static(attribute),
365            Txt::from_static(fallback),
366        )
367    }
368
369    /// Gets a handle to the lang file resource.
370    ///
371    /// The resource will be loaded and stay in memory until all clones of the handle are dropped, this
372    /// can be used to pre-load resources so that localized messages find it immediately avoiding flashing
373    /// the fallback text in the UI.
374    ///
375    /// If the resource directory or file changes it is auto-reloaded, just like when a message variable
376    /// held on the resource does.
377    ///
378    /// # Params
379    ///
380    /// * `lang`: Language identifier.
381    /// * `file`: Name of the resource file, in the default directory layout the file is searched at `dir/{lang}/{file}.ftl`, if
382    ///   empty the file is searched at `dir/{lang}/_.ftl`. Only a single file name is valid, no other path components allowed.
383    ///   Note that the file can also be a full [`LangFilePath`] that includes dependency package info. Those files are searched in
384    ///   `dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`.
385    ///
386    /// Panics if the file is invalid.
387    pub fn lang_resource(&self, lang: impl Into<Lang>, file: impl Into<LangFilePath>) -> LangResource {
388        L10N_SV.write().lang_resource(lang.into(), file.into())
389    }
390
391    /// Gets a handle to all resource files for the `lang` after they load.
392    ///
393    /// This awaits for the available langs to load, then collect an awaits for all lang files.
394    pub async fn wait_lang(&self, lang: impl Into<Lang>) -> LangResources {
395        let lang = lang.into();
396        let mut r = vec![];
397        for (file, _) in self.available_langs().get().get(&lang).into_iter().flatten() {
398            r.push(self.lang_resource(lang.clone(), file.clone()));
399        }
400        for h in &r {
401            h.wait().await;
402        }
403        LangResources(r)
404    }
405
406    /// Gets a handle to all resource files of the first lang in `langs` that is available and loaded.
407    ///
408    /// This awaits for the available langs to load, then collect an awaits for all lang files.
409    pub async fn wait_first(&self, langs: impl Into<Langs>) -> (Option<Lang>, LangResources) {
410        let langs = langs.into();
411
412        L10N.wait_available_langs().await;
413
414        let available = L10N.available_langs().get();
415        for lang in langs.0 {
416            if let Some(files) = available.get_exact(&lang) {
417                let mut r = Vec::with_capacity(files.len());
418                for file in files.keys() {
419                    r.push(self.lang_resource(lang.clone(), file.clone()));
420                }
421                let handle = LangResources(r);
422                handle.wait().await;
423
424                return (Some(lang), handle);
425            }
426        }
427
428        (None, LangResources(vec![]))
429    }
430
431    /// Bind the command metadata to a message.
432    ///
433    /// This is automatically called by [`command!`] instances that set the metadata `l10n!: true` or `l10n!: "file"`.
434    ///
435    /// [`command!`]: zng_app::event::command!
436    pub fn bind_command_meta(
437        &self,
438        file: impl Into<LangFilePath>,
439        cmd: Command,
440        meta_name: impl Into<Txt>,
441        meta_value: CommandMetaVar<Txt>,
442    ) {
443        let msg = self.message(file, cmd.event().as_any().name(), meta_name, meta_value.get()).build();
444        meta_value.set_from(&msg).unwrap();
445
446        // bind only holds a weak ref to `meta_value`` in `msg`
447        msg.bind(&meta_value).perm();
448        meta_value
449            .hook(move |_| {
450                // keep `msg` alive to it continues updating `meta_value`
451                let _keep = &msg;
452                true
453            })
454            .perm();
455    }
456}
457
458/// <span data-del-macro-root></span> Compile-time validated [`Lang`] value.
459///
460/// The language is parsed during compile and any errors are emitted as compile time errors.
461///
462/// # Syntax
463///
464/// The input can be a single a single string literal with `-` separators, or a single ident with `_` as the separators.
465///
466/// # Examples
467///
468/// ```
469/// # use zng_ext_l10n::lang;
470/// let en_us = lang!(en_US);
471/// let en = lang!(en);
472///
473/// assert!(en.matches(&en_us, true, false));
474/// assert_eq!(en_us, lang!("en-US"));
475/// ```
476#[macro_export]
477macro_rules! lang {
478    ($($tt:tt)+) => {
479        {
480            let lang: $crate::unic_langid::LanguageIdentifier = $crate::__lang! {
481                unic_langid { $crate::unic_langid }
482                lang { $($tt)+ }
483            };
484            $crate::Lang(lang)
485        }
486    }
487}
488
489/// Represents a localization data source.
490///
491/// See [`L10N.load`] for more details.
492///
493/// [`L10N.load`]: L10N::load
494pub trait L10nSource: Send + 'static {
495    /// Gets a read-only variable with all lang files that the source can provide.
496    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>;
497    /// Gets a read-only variable that is the status of the [`available_langs`] value.
498    ///
499    /// [`available_langs`]: Self::available_langs
500    fn available_langs_status(&mut self) -> BoxedVar<LangResourceStatus>;
501
502    /// Gets a read-only variable that provides the fluent resource for the `lang` and `file` if available.
503    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>>;
504    /// Gets a read-only variable that is the status of the [`lang_resource`] value.
505    ///
506    /// [`lang_resource`]: Self::lang_resource
507    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<LangResourceStatus>;
508}
509
510fn from_unic_char_direction(d: unic_langid::CharacterDirection) -> LayoutDirection {
511    match d {
512        unic_langid::CharacterDirection::LTR => LayoutDirection::LTR,
513        unic_langid::CharacterDirection::RTL => LayoutDirection::RTL,
514        d => {
515            tracing::warn!("converted {d:?} to LTR");
516            LayoutDirection::LTR
517        }
518    }
519}