lumen_scene/script_commands.rs
1//! Applying the script commands that touch nothing but the scene.
2//!
3//! A script's builtins queue commands rather than mutating the world, and the
4//! appliers are grouped by what they need to reach. This one needs the scene
5//! and nothing else: a signal, an array, a property, an element's text. Every
6//! host has those, which is why they are here rather than beside the ones
7//! that open a file dialog or register a tray icon.
8//!
9//! A host registers this alongside its own applier, with the same ordering
10//! edges: everything that reads a signal is dirty-gated on the tick the write
11//! lands, so a write applied after its reader is a write its reader never
12//! sees. The desktop, the browser, a prehydration pass and a server render
13//! all register this one system, so a script's `set_signal` means the same
14//! thing wherever the app is running.
15
16use bevy_ecs::message::MessageReader;
17use bevy_ecs::prelude::*;
18use bevy_ecs::system::SystemParam;
19use lumen_core::components::{DefaultLayoutDirection, LumenId, TextContent, TextInput};
20use lumen_core::i18n::{AppI18n, set_active_locale};
21use lumen_core::property_store::{PropertyStore, push_external_property};
22use lumen_core::signals::ArraySignals;
23use lumen_core::warn_line;
24use lumen_script::ScriptCommand;
25use lumen_script::runtime::ScriptCommandEvent;
26
27/// What a locale switch writes: the app's own i18n handle, and the base
28/// writing direction the new locale reads in.
29///
30/// Both are optional because not every world carries them. A bare world, or
31/// a host that installed no i18n, leaves a `set_locale` with nothing to
32/// switch, and it is dropped.
33#[derive(SystemParam)]
34pub struct LocaleSwitch<'w> {
35 i18n: Option<ResMut<'w, AppI18n>>,
36 direction: Option<ResMut<'w, DefaultLayoutDirection>>,
37}
38
39/// Apply the commands whose whole effect is on the scene.
40///
41/// Commands this does not answer for are left for the applier that holds
42/// what they need; each command has exactly one applier.
43///
44/// The locale switch is here rather than beside the desktop's own applier
45/// because every host has one: a page and a server render read their text
46/// out of the same catalogue the desktop does.
47pub fn apply_scene_script_commands(
48 mut events: MessageReader<ScriptCommandEvent>,
49 ids: Query<(Entity, &LumenId)>,
50 mut texts: Query<&mut TextContent>,
51 mut inputs: Query<&mut TextInput>,
52 mut store: ResMut<PropertyStore>,
53 mut array_signals: ResMut<ArraySignals>,
54 mut locale: LocaleSwitch,
55) {
56 for event in events.read() {
57 match &event.0 {
58 ScriptCommand::Print(line) => warn_line!("[script] {line}"),
59 ScriptCommand::SetText { target_id, text } => {
60 for (entity, id) in &ids {
61 if id.0 != *target_id {
62 continue;
63 }
64 if let Ok(mut content) = texts.get_mut(entity) {
65 content.0 = text.clone();
66 }
67 // Replacing text from a script must clamp the caret, or
68 // it points past the end of the new buffer and the next
69 // keystroke inserts out of bounds.
70 if let Ok(mut input) = inputs.get_mut(entity) {
71 input.cursor = text.len();
72 }
73 }
74 }
75 ScriptCommand::SetSignal { name, value } => {
76 store.set_global_str(name, value.as_str());
77 }
78 ScriptCommand::SetArray { name, items } => {
79 array_signals.set(name, items.clone());
80 }
81 ScriptCommand::SetProperty { key, value } => {
82 // A typed write, coalesced onto the next tick. A host that
83 // wants the immediate cross-thread path calls
84 // `push_external_property` itself.
85 push_external_property(key.clone(), value.clone());
86 }
87 ScriptCommand::SetLocale { tag } => {
88 let Some(i18n) = locale.i18n.as_mut() else {
89 continue;
90 };
91 let Some(change) = i18n.set_locale(tag) else {
92 tracing::warn!("set_locale: {tag:?} is not a BCP-47 locale tag");
93 continue;
94 };
95 // Raise the changed flag by hand. The switch goes through a
96 // shared handle, so it takes `&self` and the borrow alone
97 // does not raise it, and that flag is what wakes the rebuild
98 // of the tree and the refresh of every bound value.
99 i18n.set_changed();
100 // What a script's `locale()` reads back. It cannot reach the
101 // resource: a value builtin runs with no world.
102 set_active_locale(&change.locale);
103 // An app running in Arabic is mirrored with nothing in its
104 // markup saying so, and switching back to English unmirrors
105 // it. `resolve_layout_direction` is gated on this resource
106 // changing, so the write re-stamps the live tree.
107 if let Some(direction) = locale.direction.as_mut()
108 && direction.0 != change.direction
109 {
110 direction.0 = change.direction;
111 }
112 }
113 _ => {}
114 }
115 }
116}