Skip to main content

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}