use accesskit::Role;
use bevy::a11y::AccessibilityNode;
use bevy::image::Image;
use bevy::input_focus::AutoFocus;
use bevy::input_focus::tab_navigation::TabIndex;
use bevy::prelude::*;
use bevy::text::{EditableText, TextCursorStyle};
use bevy::ui::widget::NodeImageMode;
use super::stamps::{
apply_anchor, apply_button_focus_default, apply_scroll_listener, apply_scroll_step,
apply_style_variants, apply_wheel_listener, create_controlled_scroll, queue_pending_selection,
register_editable_handlers, stamp_common,
};
use super::stats::UiAssets;
use crate::bridge::{CanvasSizeTracker, JsBridge, RNode, SpanKind};
use crate::canvas::{CanvasSurface, blank_canvas_image};
use crate::plugin::Fonts;
use crate::portal::{RPortal, blank_portal_image};
use crate::protocol::{NodeId, props::Props, style::Style};
use crate::surface::RSurface;
use crate::transition::apply_scroll_transition;
use crate::ui_map::{
AtlasLayoutCache, apply_atlas, apply_style, apply_text_style, image_node, overlay_style,
resolved_text_style, svg_image_node, text_layout,
};
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_create(
commands: &mut Commands,
bridge: &mut JsBridge,
assets: &AssetServer,
fonts: &Fonts,
images: &mut Assets<Image>,
ui_assets: &mut UiAssets,
id: NodeId,
kind: String,
props: Props,
text: Option<String>,
) {
let _diag = crate::diag::node_scope(id);
let shape_kind = crate::svg::ShapeKind::from_kind(&kind);
let entity = match kind.as_str() {
"text" => {
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
ec.insert(Text::new(text.clone().unwrap_or_default()));
apply_text_style(&mut ec, &props.style, fonts);
if let Some(layout) = text_layout(&props.style) {
ec.insert(layout);
}
apply_anchor(&mut ec, &props);
ec.id()
}
"textSpan" => {
let mut ec = commands.spawn((RNode(id), TextSpan(text.clone().unwrap_or_default())));
apply_text_style(&mut ec, &props.style, fonts);
ec.id()
}
"canvas" => {
let handle = images.add(blank_canvas_image());
let mut node_img = ImageNode::new(handle);
node_img.image_mode = NodeImageMode::Stretch;
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
ec.insert((
node_img,
CanvasSurface::new(props.draw.clone().unwrap_or_default()),
CanvasSizeTracker::default(),
));
stamp_common(&mut ec, &props);
ec.id()
}
"portal" => {
let handle = images.add(blank_portal_image());
let mut node_img = ImageNode::new(handle);
node_img.image_mode = NodeImageMode::Stretch;
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
ec.insert((node_img, RPortal(props.target.clone().unwrap_or_default())));
stamp_common(&mut ec, &props);
ec.id()
}
"svg" => super::svg_ops::create_svg_root(commands, images, id, &props),
"surface" => {
let style = overlay_style(&surface_root_base(), &props.style);
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &style);
ec.insert(RSurface(props.target.clone().unwrap_or_default()));
apply_anchor(&mut ec, &props);
ec.id()
}
"root" => {
let style = overlay_style(&root_base(), &props.style);
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &style);
ec.insert((crate::bridge::RRoot, Pickable::IGNORE));
apply_anchor(&mut ec, &props);
ec.id()
}
"editableText" => {
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
let mut editable = EditableText::new(props.value.as_deref().unwrap_or_default());
editable.max_characters = props.max_length;
editable.allow_newlines = props.multiline;
let (text_color, font, line_height, letter_spacing) =
resolved_text_style(&props.style, fonts);
ec.insert((
editable,
text_color,
font,
line_height,
letter_spacing,
TextLayout {
linebreak: if props.multiline {
LineBreak::WordBoundary
} else {
LineBreak::NoWrap
},
..default()
},
TextCursorStyle {
color: text_color.0,
..default()
},
TabIndex(0),
AccessibilityNode(editable_a11y_node(&props)),
));
if props.autofocus {
ec.insert(AutoFocus);
}
apply_style_variants(&mut ec, &props);
apply_anchor(&mut ec, &props);
ec.id()
}
_ => match shape_kind {
Some(shape) => super::svg_ops::create_shape(commands, id, shape, &props),
None => spawn_element(
commands,
id,
&kind,
&props,
assets,
&mut ui_assets.layouts,
&mut ui_assets.atlas_cache,
),
},
};
if matches!(kind.as_str(), "text" | "textSpan") {
bridge
.text_styles
.insert(id, resolved_text_style(&props.style, fonts));
}
if kind == "textSpan" {
bridge.spans.insert(id, SpanKind::InlineStyled);
}
if kind == "editableText" {
bridge.editable_inputs.insert(id);
bridge
.editable_values
.insert(id, props.value.clone().unwrap_or_default());
register_editable_handlers(bridge, id, &props);
queue_pending_selection(bridge, id, props.selection_start, props.selection_end);
}
if kind == "surface" {
bridge.surfaces.insert(id);
}
if kind == "root" {
bridge.roots.insert(id);
}
if kind == "svg" {
bridge.svg_roots.insert(id);
}
if shape_kind.is_some() {
bridge.shapes.insert(id);
}
{
let mut ec = commands.entity(entity);
apply_scroll_listener(&mut ec, &props);
apply_wheel_listener(&mut ec, &props);
apply_scroll_step(&mut ec, &props);
apply_scroll_transition(&mut ec, &props.style);
create_controlled_scroll(bridge, &mut ec, id, &props);
}
match kind.as_str() {
"image" | "canvas" | "portal" | "svg" => {
bridge.foreign_images.insert(id);
let element: &'static str = match kind.as_str() {
"image" => "image",
"canvas" => "canvas",
"portal" => "portal",
"svg" => "svg",
_ => unreachable!("guarded by the outer arm"),
};
crate::background_image::warn_ignored(element, &props);
}
"surface" => crate::background_image::warn_ignored("surface", &props),
"textSpan" => {}
_ if shape_kind.is_some() => {}
_ => {
let mut ec = commands.entity(entity);
crate::background_image::apply_background_image(
&mut ec,
&props.style,
crate::protocol::style::StyleDirty::ALL,
false,
assets,
);
}
}
bridge.nodes.insert(id, entity);
if props.style.as_ref().is_some_and(|s| s.cache.is_some())
|| props
.all_styles()
.any(|s| s.filter.is_some() || s.backdrop_filter.is_some())
{
bridge.layer_dirty.insert(id);
}
let (state, _) = props.split_events();
bridge.props_cache.insert(id, Box::new(state));
}
fn spawn_element(
commands: &mut Commands,
id: NodeId,
kind: &str,
props: &Props,
assets: &AssetServer,
layouts: &mut Assets<TextureAtlasLayout>,
atlas_cache: &mut AtlasLayoutCache,
) -> Entity {
let mut ec = commands.spawn(RNode(id));
apply_style(&mut ec, &props.style);
match kind {
"button" => {
ec.insert(Button);
apply_button_focus_default(&mut ec, &props.style);
}
"image" if props.src.as_deref().is_some_and(crate::svg::is_svg_src) => {
crate::svg::warn_ignored_attrs(props.atlas.is_some(), props.source_rect.is_some());
let path = props.src.clone().expect("guarded by the arm");
let img = svg_image_node(props, false);
ec.queue(move |entity: EntityWorldMut| crate::svg::ensure_svg_image(entity, path, img));
}
"image" => {
let mut img = image_node(props, assets);
apply_atlas(&mut img, props, layouts, atlas_cache);
ec.insert(img);
}
_ => {}
}
stamp_common(&mut ec, props);
ec.id()
}
pub(super) fn surface_root_base() -> Option<Style> {
Some(Style {
width: Some(crate::protocol::animatable::Animatable::Static(
crate::protocol::units::Length::Percent(100.0),
)),
height: Some(crate::protocol::animatable::Animatable::Static(
crate::protocol::units::Length::Percent(100.0),
)),
..Default::default()
})
}
pub(super) fn root_base() -> Option<Style> {
Some(Style {
width: Some(crate::protocol::animatable::Animatable::Static(
crate::protocol::units::Length::Percent(100.0),
)),
height: Some(crate::protocol::animatable::Animatable::Static(
crate::protocol::units::Length::Percent(100.0),
)),
flex_direction: Some(FlexDirection::Column),
global_z_index: Some(1),
..Default::default()
})
}
pub(super) fn editable_a11y_node(props: &Props) -> accesskit::Node {
let role = if props.multiline {
Role::MultilineTextInput
} else {
Role::TextInput
};
let mut node = accesskit::Node::new(role);
if let Some(label) = &props.aria_label {
node.set_label(label.clone());
}
node.set_value(props.value.clone().unwrap_or_default());
node
}
#[cfg(test)]
mod tests {
use super::super::test_util::{children_of, create_node, ent, ordering_app, update_delta};
use super::*;
use crate::protocol::{ROOT_ID, op::Op};
#[test]
fn portal_mounts_with_target_and_rebinds() {
use bevy::ui::widget::ImageNode;
let (mut app, tx, _root) = ordering_app();
tx.send(vec![Op::Create {
id: 1,
kind: "portal".into(),
props: serde_json::from_value(serde_json::json!({ "target": "follow" }))
.expect("valid portal props"),
text: None,
}])
.unwrap();
app.update();
let e = ent(&app, 1);
assert_eq!(
app.world().entity(e).get::<RPortal>().map(|p| p.0.clone()),
Some("follow".to_string()),
"a portal carries its target name"
);
assert!(
app.world().entity(e).get::<ImageNode>().is_some(),
"a portal is backed by an ImageNode"
);
tx.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({ "target": "minimap" }))
.expect("valid portal props"),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world().entity(e).get::<RPortal>().map(|p| p.0.clone()),
Some("minimap".to_string()),
"an update rebinds the portal's target name"
);
}
#[test]
fn background_image_mounts_on_plain_node() {
use crate::background_image::{BackgroundTileScale, RBackgroundTexture};
use bevy::ui::widget::{ImageNode, NodeImageMode};
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({
"style": { "backgroundImage": {
"src": "images/bg.png", "mode": "repeat", "scale": 2.0
} }
}))
.expect("valid props"),
text: None,
},
Op::Create {
id: 2,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({
"style": { "backgroundImage": { "src": { "texture": "minimap" } } }
}))
.expect("valid props"),
text: None,
},
])
.unwrap();
app.update();
let e = ent(&app, 1);
let img = app
.world()
.entity(e)
.get::<ImageNode>()
.expect("backgroundImage inserts an ImageNode");
match img.image_mode {
NodeImageMode::Tiled {
tile_x,
tile_y,
stretch_value,
} => {
assert!(tile_x && tile_y, "repeat tiles both axes");
assert_eq!(stretch_value, 2.0, "wire scale lands in stretch_value");
}
ref other => panic!("expected Tiled, got {other:?}"),
}
assert_eq!(
app.world()
.entity(e)
.get::<BackgroundTileScale>()
.map(|s| s.0),
Some(2.0)
);
assert!(app.world().entity(e).get::<RBackgroundTexture>().is_none());
let e2 = ent(&app, 2);
assert_eq!(
app.world()
.entity(e2)
.get::<RBackgroundTexture>()
.map(|t| t.0.clone()),
Some("minimap".to_string()),
"a texture source stamps the bind marker"
);
assert!(
matches!(
app.world()
.entity(e2)
.get::<ImageNode>()
.unwrap()
.image_mode,
NodeImageMode::Stretch
),
"default mode is Stretch"
);
}
#[test]
fn background_image_ignored_on_canvas() {
use crate::background_image::{BackgroundTileScale, RBackgroundTexture};
use bevy::ui::widget::ImageNode;
let (mut app, tx, _root) = ordering_app();
tx.send(vec![Op::Create {
id: 1,
kind: "canvas".into(),
props: serde_json::from_value(serde_json::json!({
"style": { "backgroundImage": {
"src": { "texture": "x" }, "mode": "repeat"
} }
}))
.expect("valid props"),
text: None,
}])
.unwrap();
app.update();
let e = ent(&app, 1);
assert!(
app.world().entity(e).get::<ImageNode>().is_some(),
"the canvas keeps its own ImageNode"
);
assert!(app.world().entity(e).get::<RBackgroundTexture>().is_none());
assert!(app.world().entity(e).get::<BackgroundTileScale>().is_none());
}
#[test]
fn anchor_kind_mounts_and_rebinds() {
use crate::anchor::Anchored;
let (mut app, tx, _root) = ordering_app();
let target = app.world_mut().spawn_empty().id();
let bits = target.to_bits() as f64;
tx.send(vec![Op::Create {
id: 1,
kind: "anchor".into(),
props: serde_json::from_value(serde_json::json!({
"anchor": { "entity": bits, "offset": [0.0, 1.0, 0.0] }
}))
.expect("valid anchor props"),
text: None,
}])
.unwrap();
app.update();
let e = ent(&app, 1);
assert!(
app.world().entity(e).get::<Node>().is_some(),
"an anchor is a plain node host element"
);
let anchored = app
.world()
.entity(e)
.get::<Anchored>()
.expect("the anchor prop stamps an Anchored binding")
.clone();
assert_eq!(anchored.target, target, "follows the wire entity");
assert_eq!(anchored.offset, Vec3::new(0.0, 1.0, 0.0));
tx.send(vec![update_delta(
1,
serde_json::from_value(serde_json::json!({
"anchor": { "entity": bits, "offset": [0.0, 2.0, 0.0] }
}))
.expect("valid anchor props"),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world().entity(e).get::<Anchored>().map(|a| a.offset),
Some(Vec3::new(0.0, 2.0, 0.0)),
"a delta rebinds the anchor offset"
);
tx.send(vec![update_delta(1, Props::default(), &["anchor"], &[])])
.unwrap();
app.update();
assert!(
app.world().entity(e).get::<Anchored>().is_none(),
"unset removes the Anchored binding"
);
}
#[test]
fn surface_mounts_detached_with_name() {
let (mut app, tx, _root) = ordering_app();
tx.send(vec![
create_node(1), Op::Create {
id: 2,
kind: "surface".into(),
props: serde_json::from_value(serde_json::json!({ "target": "monitor" }))
.expect("valid surface props"),
text: None,
},
Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
let surface = ent(&app, 2);
assert_eq!(
app.world()
.entity(surface)
.get::<RSurface>()
.map(|s| s.0.clone()),
Some("monitor".to_string()),
"a surface carries its name in RSurface"
);
assert!(
app.world().entity(surface).get::<ChildOf>().is_none(),
"a surface is a detached root — never parented into the on-screen tree"
);
assert!(
children_of(&app, ent(&app, 1)).is_empty(),
"the surface's React parent has no Bevy children"
);
tx.send(vec![update_delta(
2,
serde_json::from_value(serde_json::json!({ "target": "panel" }))
.expect("valid surface props"),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world()
.entity(surface)
.get::<RSurface>()
.map(|s| s.0.clone()),
Some("panel".to_string()),
"an update rebinds the surface name"
);
assert!(
app.world()
.entity(surface)
.get::<crate::portal::RPortal>()
.is_none(),
"a surface update must not stamp an RPortal (shared `target` field)"
);
}
#[test]
fn root_mounts_detached_screen_space() {
use crate::bridge::RRoot;
let (mut app, tx, _ui_root) = ordering_app();
tx.send(vec![
create_node(1), Op::Create {
id: 2,
kind: "root".into(),
props: Box::default(),
text: None,
},
create_node(3), Op::Append {
parent: ROOT_ID,
child: 1,
},
Op::Append {
parent: 1,
child: 2,
},
Op::Append {
parent: 2,
child: 3,
},
])
.unwrap();
app.update();
let root_e = ent(&app, 2);
assert!(
app.world().entity(root_e).get::<RRoot>().is_some(),
"a <root> carries the RRoot marker"
);
assert!(
app.world().entity(root_e).get::<ChildOf>().is_none(),
"a <root> is a detached root — never parented into the on-screen tree"
);
assert!(
children_of(&app, ent(&app, 1)).is_empty(),
"the <root>'s React parent has no Bevy children"
);
assert_eq!(
app.world()
.entity(root_e)
.get::<GlobalZIndex>()
.map(|z| z.0),
Some(1),
"a <root> floats just above the window tree by default"
);
assert_eq!(
app.world().entity(root_e).get::<Pickable>(),
Some(&Pickable::IGNORE),
"the <root> itself must not block or hover picking"
);
assert_eq!(
children_of(&app, root_e),
vec![ent(&app, 3)],
"the <root>'s own children attach to it normally"
);
assert_eq!(
app.world()
.entity(root_e)
.get::<Node>()
.map(|n| n.flex_direction),
Some(FlexDirection::Column),
"a <root> defaults to a column, like the main UI root (not Bevy's row)"
);
tx.send(vec![update_delta(
2,
serde_json::from_value(serde_json::json!({ "style": { "padding": 4 } }))
.expect("valid root props"),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(
app.world()
.entity(root_e)
.get::<GlobalZIndex>()
.map(|z| z.0),
Some(1),
"a re-render must re-assert the baked globalZIndex, not strip it"
);
tx.send(vec![Op::Remove {
parent: ROOT_ID,
child: 1,
}])
.unwrap();
app.update();
assert!(
!app.world().entities().contains(root_e),
"removing a React ancestor must despawn the detached <root>"
);
let bridge = app.world().resource::<JsBridge>();
assert!(
bridge.roots.is_empty() && !bridge.nodes.contains_key(&2),
"the <root>'s bookkeeping must be pruned on removal"
);
}
}