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}