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