use bevy::picking::hover::HoverMap;
use bevy::picking::pointer::PointerId;
#[cfg(feature = "custom_cursor")]
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
#[cfg(feature = "custom_cursor")]
use bevy::window::{CustomCursor, CustomCursorImage};
#[derive(Component, Debug, Clone)]
pub struct NodeCursor(pub String);
#[cfg(feature = "custom_cursor")]
#[derive(Resource, Default)]
pub struct CustomCursors(pub HashMap<String, CustomCursorImage>);
#[cfg(not(feature = "custom_cursor"))]
#[derive(Resource, Default)]
pub struct CustomCursors;
#[cfg_attr(not(feature = "custom_cursor"), allow(unused_variables))]
fn resolve_cursor(name: &str, custom: &CustomCursors) -> CursorIcon {
#[cfg(feature = "custom_cursor")]
if let Some(image) = custom.0.get(name) {
return CursorIcon::Custom(CustomCursor::Image(image.clone()));
}
if let Some(icon) = system_cursor_keyword(name) {
CursorIcon::from(icon)
} else {
#[cfg(feature = "custom_cursor")]
let msg = format!("unknown cursor {name:?}");
#[cfg(not(feature = "custom_cursor"))]
let msg = format!(
"unknown cursor {name:?} (custom cursors need bevy-react's `custom_cursor` feature)"
);
crate::diag::report("cursor", name, &msg);
CursorIcon::from(SystemCursorIcon::Default)
}
}
fn system_cursor_keyword(s: &str) -> Option<SystemCursorIcon> {
use SystemCursorIcon::*;
Some(match s {
"default" | "auto" => Default,
"contextMenu" | "context-menu" => ContextMenu,
"help" => Help,
"pointer" => Pointer,
"progress" => Progress,
"wait" => Wait,
"cell" => Cell,
"crosshair" => Crosshair,
"text" => Text,
"verticalText" | "vertical-text" => VerticalText,
"alias" => Alias,
"copy" => Copy,
"move" => Move,
"noDrop" | "no-drop" => NoDrop,
"notAllowed" | "not-allowed" => NotAllowed,
"grab" => Grab,
"grabbing" => Grabbing,
"eResize" | "e-resize" => EResize,
"nResize" | "n-resize" => NResize,
"neResize" | "ne-resize" => NeResize,
"nwResize" | "nw-resize" => NwResize,
"sResize" | "s-resize" => SResize,
"seResize" | "se-resize" => SeResize,
"swResize" | "sw-resize" => SwResize,
"wResize" | "w-resize" => WResize,
"ewResize" | "ew-resize" => EwResize,
"nsResize" | "ns-resize" => NsResize,
"neswResize" | "nesw-resize" => NeswResize,
"nwseResize" | "nwse-resize" => NwseResize,
"colResize" | "col-resize" => ColResize,
"rowResize" | "row-resize" => RowResize,
"allScroll" | "all-scroll" => AllScroll,
"zoomIn" | "zoom-in" => ZoomIn,
"zoomOut" | "zoom-out" => ZoomOut,
_ => return None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn drive_cursor_icon(
mut commands: Commands,
windows: Query<(Entity, Option<&CursorIcon>), With<PrimaryWindow>>,
hover_map: Option<Res<HoverMap>>,
virtual_pointers: Option<Res<crate::ext::VirtualPointers>>,
transform3d_pointer: Option<Res<crate::layer::pick3d::Transform3dPointer>>,
custom_cursors: Res<CustomCursors>,
node_cursors: Query<&NodeCursor>,
child_of: Query<&ChildOf>,
rnodes: Query<&crate::bridge::ReactNode>,
) {
let Ok((window_entity, current)) = windows.single() else {
return;
};
let named = hover_map.as_deref().and_then(|hover_map| {
virtual_pointers
.iter()
.flat_map(|pointers| pointers.iter())
.chain(transform3d_pointer.as_deref().map(|pointer| pointer.id))
.chain([PointerId::Mouse])
.find_map(|id| hovered_cursor(id, hover_map, &node_cursors, &child_of))
});
let desired = {
let _diag = named
.as_ref()
.and_then(|(_, e)| rnodes.get(*e).ok())
.map(|r| crate::diag::node_scope(r.0));
resolve_cursor(
named.as_ref().map(|(n, _)| n.as_str()).unwrap_or("default"),
&custom_cursors,
)
};
if current != Some(&desired) {
commands.entity(window_entity).insert(desired);
}
}
fn hovered_cursor(
pointer_id: PointerId,
hover_map: &HoverMap,
node_cursors: &Query<&NodeCursor>,
child_of: &Query<&ChildOf>,
) -> Option<(String, Entity)> {
let (_, owner) = hover_map
.get(&pointer_id)?
.iter()
.filter_map(|(&hovered, hit)| {
crate::reconcile::climb(hovered, child_of, |e| node_cursors.contains(e))
.map(|owner| (hit.depth, owner))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
node_cursors.get(owner).ok().map(|c| (c.0.clone(), owner))
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::RunSystemOnce;
use bevy::picking::backend::HitData;
fn run(stack: &[Option<&str>]) -> Option<CursorIcon> {
let mut world = World::new();
let window_entity = world.spawn((Window::default(), PrimaryWindow)).id();
let mut hovered = EntityHashMap::default();
for (i, name) in stack.iter().enumerate() {
let mut node = world.spawn_empty();
if let Some(name) = name {
node.insert(NodeCursor(name.to_string()));
}
let depth = i as f32 * 0.00001;
hovered.insert(
node.id(),
HitData::new(Entity::PLACEHOLDER, depth, None, None),
);
}
let mut hover_map = HoverMap::default();
hover_map.insert(PointerId::Mouse, hovered);
world.insert_resource(hover_map);
world.init_resource::<CustomCursors>();
world.run_system_once(drive_cursor_icon).unwrap();
world.entity(window_entity).get::<CursorIcon>().cloned()
}
#[test]
fn topmost_node_under_pointer_wins() {
let icon = run(&[Some("pointer"), Some("grab")]);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Pointer)));
}
#[test]
fn a_node_without_a_cursor_shows_the_one_beneath() {
let icon = run(&[None, Some("text")]);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Text)));
}
#[test]
fn hovering_no_cursor_node_resets_to_default() {
let stacks: [&[Option<&str>]; 2] = [&[], &[None]];
for stack in stacks {
let icon = run(stack);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Default)));
}
}
#[test]
fn a_clipped_away_node_does_not_set_the_cursor() {
use crate::reconcile::clip_pick_tests::{OVER_CLIPPED, OVER_SHOWN, clip_scene};
let icon = |pointer: Vec2| {
let mut scene = clip_scene(pointer);
scene.app.init_resource::<CustomCursors>();
scene.app.add_systems(Update, drive_cursor_icon);
let world = scene.app.world_mut();
world
.entity_mut(scene.shown)
.insert(NodeCursor("text".to_string()));
world
.entity_mut(scene.clipped)
.insert(NodeCursor("pointer".to_string()));
scene.app.update();
scene.app.update();
let window = scene.app.world().entity(scene.window);
window.get::<CursorIcon>().cloned()
};
assert_eq!(
icon(OVER_SHOWN),
Some(CursorIcon::from(SystemCursorIcon::Text))
);
assert_eq!(
icon(OVER_CLIPPED),
Some(CursorIcon::from(SystemCursorIcon::Default))
);
}
#[cfg(feature = "custom_cursor")]
#[test]
fn registered_default_becomes_app_cursor() {
let mut world = World::new();
let window_entity = world.spawn((Window::default(), PrimaryWindow)).id();
let arrow = CustomCursorImage {
handle: Handle::default(),
hotspot: (0, 0),
..default()
};
let mut registry = CustomCursors::default();
registry.0.insert("default".into(), arrow.clone());
world.insert_resource(registry);
world.run_system_once(drive_cursor_icon).unwrap();
assert_eq!(
world.entity(window_entity).get::<CursorIcon>().cloned(),
Some(CursorIcon::Custom(CustomCursor::Image(arrow))),
);
}
#[test]
fn hovered_leaf_uses_its_ancestors_cursor() {
let mut world = World::new();
let parent = world.spawn(NodeCursor("pointer".to_string())).id();
let leaf = world.spawn(ChildOf(parent)).id();
let behind = world.spawn(NodeCursor("grab".to_string())).id();
let pointer_id = PointerId::Mouse;
let mut hovered = EntityHashMap::default();
hovered.insert(leaf, HitData::new(Entity::PLACEHOLDER, 0.0, None, None));
hovered.insert(behind, HitData::new(Entity::PLACEHOLDER, 1.0, None, None));
let mut hover_map = HoverMap::default();
hover_map.insert(pointer_id, hovered);
world.insert_resource(hover_map);
let hit = world
.run_system_once(
move |hm: Res<HoverMap>, ncs: Query<&NodeCursor>, co: Query<&ChildOf>| {
hovered_cursor(pointer_id, &hm, &ncs, &co)
},
)
.unwrap();
assert_eq!(hit, Some(("pointer".to_string(), parent)));
}
#[test]
fn hover_without_a_cursor_yields_none() {
let mut world = World::new();
let bare = world.spawn_empty().id();
let pointer_id = PointerId::Mouse;
let mut hovered = EntityHashMap::default();
hovered.insert(bare, HitData::new(Entity::PLACEHOLDER, 0.0, None, None));
let mut hover_map = HoverMap::default();
hover_map.insert(pointer_id, hovered);
world.insert_resource(hover_map);
let hit = world
.run_system_once(
move |hm: Res<HoverMap>, ncs: Query<&NodeCursor>, co: Query<&ChildOf>| {
hovered_cursor(pointer_id, &hm, &ncs, &co)
},
)
.unwrap();
assert_eq!(hit, None);
}
#[cfg(feature = "custom_cursor")]
#[test]
fn custom_cursor_overrides_and_resolves() {
let mut registry = CustomCursors::default();
let hand = CustomCursorImage {
handle: Handle::default(),
hotspot: (4, 2),
..default()
};
registry.0.insert("hand".into(), hand.clone());
registry.0.insert("pointer".into(), hand.clone());
assert_eq!(
resolve_cursor("hand", ®istry),
CursorIcon::Custom(CustomCursor::Image(hand.clone())),
);
assert_eq!(
resolve_cursor("pointer", ®istry),
CursorIcon::Custom(CustomCursor::Image(hand)),
);
assert_eq!(
resolve_cursor("text", ®istry),
CursorIcon::from(SystemCursorIcon::Text),
);
assert_eq!(
resolve_cursor("missing", ®istry),
CursorIcon::from(SystemCursorIcon::Default),
);
}
}