Skip to main content

lumen_scene/
i18n.rs

1//! Rebuilding what the tree says when the app changes locale.
2//!
3//! An app's locale is not fixed for the life of the process: a language
4//! menu changes it, and everything the old locale wrote has to be written
5//! again. The spawner left the raw material behind for exactly this, as
6//! [`AuthoredStrings`] on every element whose text came from a catalogue or
7//! went through a `format`, so a switch resolves the same strings against
8//! the catalogue in force now rather than re-reading the markup. A shipped
9//! app has no markup to re-read.
10//!
11//! What follows a switch: a marked element's text, a text entry's
12//! placeholder, a tooltip's body, and the output of a `format` spec. What
13//! does not: a string a script composed and set with `set_text` on an
14//! element that names no key, and anything built once at startup outside
15//! the tree, such as a native menu or a tray label.
16
17use bevy_ecs::prelude::*;
18use lumen_core::components::{AuthoredStrings, BindText, TextContent, TextFormat, TextInput};
19use lumen_core::i18n::AppI18n;
20use lumen_core::input::Focused;
21use lumen_core::prelude::{App, TickStage};
22use lumen_ir::translate::TranslatedStrings;
23use lumen_primitives::TooltipSource;
24
25use crate::script_commands::apply_scene_script_commands;
26use crate::spawn::resolve_strings;
27
28/// Register the rebuild, after the applier that performs the switch, so a
29/// locale a script picks lands on the tick it was picked on.
30///
31/// The order is not the host's to choose: running the rebuild before the
32/// applier leaves the tree a tick behind the locale, which shows up as a
33/// language menu whose entries take two clicks.
34pub fn install_retranslate(app: &mut App) {
35    app.add_systems(
36        TickStage::Systems,
37        retranslate_on_locale_change.after(apply_scene_script_commands),
38    );
39}
40
41/// Every element that kept its authored strings, with each thing a locale
42/// writes on it: its text, its entry's placeholder and caret, its tooltip's
43/// body, and whether an edit is in flight.
44type Retranslated<'w, 's> = Query<
45    'w,
46    's,
47    (
48        &'static AuthoredStrings,
49        Option<&'static TextFormat>,
50        Option<&'static mut TextContent>,
51        Option<&'static mut TextInput>,
52        Option<&'static mut TooltipSource>,
53        Option<&'static Focused>,
54    ),
55    Without<BindText>,
56>;
57
58/// Resolve every element that kept its authored strings against the locale
59/// in force, and write back what changed.
60///
61/// Gated on [`AppI18n`] being marked changed, which is what the applier
62/// does when it switches the locale through a `ResMut` borrow. A steady
63/// tick costs one flag read.
64///
65/// An element carrying a `bind-text` is left alone: it belongs to its
66/// signal, and [`lumen_core::signals::apply_text_bindings`] refreshes it on
67/// the same tick. [`write_strings`] says what a focused entry keeps.
68pub fn retranslate_on_locale_change(i18n: Option<Res<AppI18n>>, mut q: Retranslated<'_, '_>) {
69    let Some(i18n) = i18n else {
70        return;
71    };
72    if !i18n.is_changed() {
73        return;
74    }
75    for (authored, format, text, input, tooltip, focused) in &mut q {
76        let strings = resolve_strings(Some(&*i18n), authored, format.map(|f| f.0.as_str()));
77        write_strings(strings, text, input, tooltip, focused.is_some());
78    }
79}
80
81/// Write an element's resolved strings onto the components that show them,
82/// touching only what changed.
83///
84/// Shared by the locale rebuild and by a `<for>` row whose record changed
85/// under it, so both follow one rule. `editing` says the element holds
86/// focus: a focused text entry has an edit in flight, and overwriting the
87/// buffer under the caret would lose the keystroke; its placeholder still
88/// moves, since nothing is typing into that.
89pub(crate) fn write_strings(
90    strings: TranslatedStrings,
91    text: Option<Mut<'_, TextContent>>,
92    mut input: Option<Mut<'_, TextInput>>,
93    tooltip: Option<Mut<'_, TooltipSource>>,
94    editing: bool,
95) {
96    // A placeholder shows while the field is empty, so it moves even
97    // mid-edit; the buffer under the caret does not.
98    if let Some(input) = input.as_mut() {
99        let want = strings.placeholder.unwrap_or_default();
100        if input.placeholder != want {
101            input.placeholder = want;
102        }
103    }
104    if let Some(mut tooltip) = tooltip
105        && let Some(body) = &strings.tooltip
106        && tooltip.text != *body
107    {
108        tooltip.text = body.clone();
109    }
110    // The edit in flight the gate protects: a focused text buffer, whose
111    // next keystroke would land on the text this write replaces.
112    if editing && input.is_some() {
113        return;
114    }
115    // Only when the rule resolved a text at all: an `<input
116    // translatable="search" placeholder="Search"/>` has none, and its
117    // typed value is not the catalogue's to replace.
118    let (Some(mut content), Some(want)) = (text, strings.text) else {
119        return;
120    };
121    if content.0 == want {
122        return;
123    }
124    content.0 = want;
125    // Byte offsets into the old text can point past the end of the new
126    // one; the next keystroke would insert out of bounds.
127    if let Some(input) = input.as_mut() {
128        if input.cursor > content.0.len() {
129            input.cursor = content.0.len();
130        }
131        if let Some(anchor) = input.selection_anchor
132            && anchor > content.0.len()
133        {
134            input.selection_anchor = None;
135        }
136    }
137}