Skip to main content

rlvgl_i18n/
lib.rs

1//! Thin compile-time i18n for rlvgl.
2//!
3//! Translations are stored in `locales/*.json`, compiled to a compact binary
4//! blob at build time, and embedded via `include_bytes!`.  Use the [`t!`]
5//! macro to look up strings:
6//!
7//! ```ignore
8//! use rlvgl_i18n::t;
9//!
10//! let label = t!("demo.plugins");              // &'static str
11//! let msg   = t!("demo.clicks", count = 42);   // String
12//! ```
13//!
14//! ## Binary blob format (RLTN v1)
15//!
16//! The `.bin` file produced by the build is self-contained and can be copied
17//! to an SD card or flash partition.  Call [`load_translations`] at runtime
18//! to override the built-in blob with one loaded from media.
19//!
20//! ```text
21//! [0..4]   magic   b"RLTN"
22//! [4]      version 1
23//! [5]      num_locales
24//! [6..8]   num_keys   (u16 LE)
25//! [8..]    entries    (num_locales × num_keys) × 6 bytes:
26//!            offset: u32 LE  (into string data region)
27//!            len:    u16 LE
28//! [..]     string data  (UTF-8, packed)
29//! ```
30#![no_std]
31
32extern crate alloc;
33
34use alloc::string::String;
35use core::fmt::{Display, Write};
36use core::sync::atomic::{AtomicPtr, AtomicU8, Ordering};
37
38// Generated: Locale enum, Key enum, BUILTIN_BLOB, t! macro, locale_from_u8.
39include!(concat!(env!("OUT_DIR"), "/translations.rs"));
40
41const HEADER_SIZE: usize = 8;
42const ENTRY_SIZE: usize = 6;
43
44static CURRENT_LOCALE: AtomicU8 = AtomicU8::new(0);
45
46/// Pointer to the active translation blob.  Defaults to the built-in blob;
47/// can be swapped at runtime via [`load_translations`].
48static ACTIVE_BLOB: AtomicPtr<u8> = AtomicPtr::new(core::ptr::null_mut());
49
50fn active_blob() -> &'static [u8] {
51    let ptr = ACTIVE_BLOB.load(Ordering::Relaxed);
52    if ptr.is_null() {
53        BUILTIN_BLOB
54    } else {
55        // Safety: load_translations ensures the pointer is valid and 'static.
56        unsafe { core::slice::from_raw_parts(ptr, blob_len(ptr)) }
57    }
58}
59
60/// Read the total blob length from its header + string data.
61///
62/// # Safety
63/// `ptr` must point to a valid RLTN blob.
64unsafe fn blob_len(ptr: *const u8) -> usize {
65    unsafe {
66        let num_locales = *ptr.add(5) as usize;
67        let num_keys = u16::from_le_bytes([*ptr.add(6), *ptr.add(7)]) as usize;
68        let num_entries = num_locales * num_keys;
69        if num_entries == 0 {
70            return HEADER_SIZE;
71        }
72        let last_entry = HEADER_SIZE + (num_entries - 1) * ENTRY_SIZE;
73        let offset = u32::from_le_bytes([
74            *ptr.add(last_entry),
75            *ptr.add(last_entry + 1),
76            *ptr.add(last_entry + 2),
77            *ptr.add(last_entry + 3),
78        ]) as usize;
79        let len = u16::from_le_bytes([*ptr.add(last_entry + 4), *ptr.add(last_entry + 5)]) as usize;
80        let data_start = HEADER_SIZE + num_entries * ENTRY_SIZE;
81        data_start + offset + len
82    }
83}
84
85/// Override the built-in translations with a blob loaded from media.
86///
87/// The data must live for `'static` (e.g. `Box::leak` a buffer read from SD).
88/// Pass `None` to revert to the built-in blob.
89///
90/// # Safety
91/// The caller must ensure `data` points to a valid RLTN v1 blob whose
92/// `num_locales` and `num_keys` match the compiled [`Locale`] and [`Key`]
93/// enums.
94pub unsafe fn load_translations(data: Option<&'static [u8]>) {
95    match data {
96        Some(d) => ACTIVE_BLOB.store(d.as_ptr() as *mut u8, Ordering::Release),
97        None => ACTIVE_BLOB.store(core::ptr::null_mut(), Ordering::Release),
98    }
99}
100
101/// Set the active locale for all subsequent `t!()` calls.
102pub fn set_locale(locale: Locale) {
103    CURRENT_LOCALE.store(locale as u8, Ordering::Relaxed);
104}
105
106/// Return the currently active locale.
107pub fn locale() -> Locale {
108    locale_from_u8(CURRENT_LOCALE.load(Ordering::Relaxed))
109}
110
111/// Look up a string in a RLTN blob.  Returns a `&str` with the blob's
112/// lifetime (always `'static` for both built-in and leaked FS blobs).
113#[inline]
114fn lookup_in(blob: &[u8], locale: Locale, key: Key) -> &str {
115    let num_keys = u16::from_le_bytes([blob[6], blob[7]]) as usize;
116    let idx = (locale as usize) * num_keys + (key as usize);
117    let base = HEADER_SIZE + idx * ENTRY_SIZE;
118    let offset =
119        u32::from_le_bytes([blob[base], blob[base + 1], blob[base + 2], blob[base + 3]]) as usize;
120    let len = u16::from_le_bytes([blob[base + 4], blob[base + 5]]) as usize;
121    let data_start = HEADER_SIZE + num_keys * (blob[5] as usize) * ENTRY_SIZE;
122    let start = data_start + offset;
123    // Safety: build.rs guarantees valid UTF-8 in the string data region.
124    unsafe { core::str::from_utf8_unchecked(&blob[start..start + len]) }
125}
126
127/// Look up a plain (non-parameterized) translation for the current locale.
128#[inline]
129pub fn t_static(key: Key) -> &'static str {
130    // Safety: active_blob() always returns a 'static reference.
131    lookup_in(active_blob(), locale(), key)
132}
133
134/// Format a translation template by replacing `{name}` placeholders.
135pub fn t_format(key: Key, params: &[(&str, &dyn Display)]) -> String {
136    let template = t_static(key);
137    let mut out = String::with_capacity(template.len() + 16);
138    let mut rest = template;
139    while let Some(start) = rest.find('{') {
140        out.push_str(&rest[..start]);
141        let after = &rest[start + 1..];
142        if let Some(end) = after.find('}') {
143            let name = &after[..end];
144            if let Some((_, val)) = params.iter().find(|(n, _)| *n == name) {
145                let _ = write!(out, "{}", val);
146            } else {
147                out.push('{');
148                out.push_str(name);
149                out.push('}');
150            }
151            rest = &after[end + 1..];
152        } else {
153            out.push_str(&rest[start..]);
154            break;
155        }
156    }
157    out.push_str(rest);
158    out
159}
160
161/// Return a reference to the built-in binary translation blob.
162///
163/// Useful for diagnostics or for writing the blob to media.
164pub fn builtin_blob() -> &'static [u8] {
165    BUILTIN_BLOB
166}
167
168#[cfg(test)]
169mod tests {
170    extern crate std;
171    use super::*;
172
173    #[test]
174    fn blob_header() {
175        assert_eq!(&BUILTIN_BLOB[..4], b"RLTN");
176        assert_eq!(BUILTIN_BLOB[4], 1); // version
177    }
178
179    #[test]
180    fn plain_lookup() {
181        set_locale(Locale::En);
182        assert_eq!(t!("demo.plugins"), "Plugins");
183    }
184
185    #[test]
186    fn parameterized() {
187        set_locale(Locale::En);
188        let s = t!("demo.clicks", count = 42);
189        assert_eq!(s, "Clicks: 42");
190    }
191
192    #[test]
193    fn locale_switch() {
194        set_locale(Locale::Fr);
195        assert_eq!(t!("demo.plugins"), "Extensions");
196        set_locale(Locale::En);
197        assert_eq!(t!("demo.plugins"), "Plugins");
198    }
199
200    #[test]
201    fn parameterized_fr() {
202        set_locale(Locale::Fr);
203        let s = t!("demo.clicks", count = 7);
204        assert_eq!(s, "Clics : 7");
205    }
206}