use crate::surface::SurfaceVirtualPointer;
use bevy::picking::hover::HoverMap;
use bevy::picking::pointer::PointerId;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform, UiStack};
use bevy::window::{CursorIcon, CustomCursor, CustomCursorImage, PrimaryWindow, SystemCursorIcon};
#[derive(Component, Debug, Clone)]
pub struct NodeCursor(pub String);
#[derive(Resource, Default)]
pub struct CustomCursors(pub HashMap<String, CustomCursorImage>);
fn resolve_cursor(name: &str, custom: &CustomCursors) -> CursorIcon {
if let Some(image) = custom.0.get(name) {
CursorIcon::Custom(CustomCursor::Image(image.clone()))
} else if let Some(icon) = system_cursor_keyword(name) {
CursorIcon::from(icon)
} else {
warn!(target: "bevy_react", "unknown cursor {name:?}; using the default");
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::type_complexity, clippy::too_many_arguments)]
pub fn drive_cursor_icon(
mut commands: Commands,
windows: Query<(Entity, &Window, Option<&CursorIcon>), With<PrimaryWindow>>,
ui_stack: Res<UiStack>,
windowed: Query<(&ComputedNode, &UiGlobalTransform, &NodeCursor)>,
hover_map: Option<Res<HoverMap>>,
surface_pointer: Option<Res<SurfaceVirtualPointer>>,
custom_cursors: Res<CustomCursors>,
node_cursors: Query<&NodeCursor>,
child_of: Query<&ChildOf>,
) {
let Ok((window_entity, window, current)) = windows.single() else {
return;
};
let name = surface_pointer
.as_deref()
.zip(hover_map.as_deref())
.and_then(|(pointer, hover_map)| {
surface_cursor_for(pointer.id, hover_map, &node_cursors, &child_of)
})
.or_else(|| window_cursor(window, &ui_stack, &windowed));
let desired = resolve_cursor(name.as_deref().unwrap_or("default"), &custom_cursors);
if current != Some(&desired) {
commands.entity(window_entity).insert(desired);
}
}
fn window_cursor(
window: &Window,
ui_stack: &UiStack,
windowed: &Query<(&ComputedNode, &UiGlobalTransform, &NodeCursor)>,
) -> Option<String> {
let cursor = window.cursor_position()? * window.scale_factor();
ui_stack.uinodes.iter().rev().find_map(|&entity| {
let (computed, transform, node_cursor) = windowed.get(entity).ok()?;
computed
.contains_point(*transform, cursor)
.then(|| node_cursor.0.clone())
})
}
fn surface_cursor_for(
pointer_id: PointerId,
hover_map: &HoverMap,
node_cursors: &Query<&NodeCursor>,
child_of: &Query<&ChildOf>,
) -> Option<String> {
let (&top, _) = hover_map
.get(&pointer_id)?
.iter()
.min_by(|a, b| a.1.depth.total_cmp(&b.1.depth))?;
let owner = crate::reconcile::climb(top, child_of, |e| node_cursors.contains(e))?;
node_cursors.get(owner).ok().map(|c| c.0.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::RunSystemOnce;
use bevy::picking::backend::HitData;
fn run(cursor: Option<Vec2>, stack: &[&str]) -> Option<CursorIcon> {
let mut world = World::new();
let mut window = Window::default();
window.set_physical_cursor_position(cursor.map(|c| c.as_dvec2()));
let window_entity = world.spawn((window, PrimaryWindow)).id();
let nodes: Vec<Entity> = stack
.iter()
.map(|&name| {
world
.spawn((
ComputedNode {
size: Vec2::new(200.0, 100.0),
inverse_scale_factor: 1.0,
..default()
},
UiGlobalTransform::from_translation(Vec2::new(300.0, 200.0)),
NodeCursor(name.to_string()),
))
.id()
})
.collect();
world.insert_resource(UiStack {
uinodes: nodes,
partition: Vec::new(),
});
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(Vec2::new(300.0, 200.0)), &["grab", "pointer"]);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Pointer)));
}
#[test]
fn single_node_sets_its_cursor() {
let icon = run(Some(Vec2::new(300.0, 200.0)), &["text"]);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Text)));
}
#[test]
fn pointer_off_all_nodes_resets_to_default() {
let icon = run(Some(Vec2::new(50.0, 50.0)), &["pointer"]);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Default)));
}
#[test]
fn no_cursor_position_resets_to_default() {
let icon = run(None, &["pointer"]);
assert_eq!(icon, Some(CursorIcon::from(SystemCursorIcon::Default)));
}
#[test]
fn registered_default_becomes_app_cursor() {
let mut world = World::new();
let mut window = Window::default();
window.set_physical_cursor_position(Some(Vec2::new(10.0, 10.0).as_dvec2()));
let window_entity = world.spawn((window, PrimaryWindow)).id();
world.insert_resource(UiStack {
uinodes: Vec::new(),
partition: Vec::new(),
});
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 surface_pointer_uses_hovered_node_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 icon = world
.run_system_once(
move |hm: Res<HoverMap>, ncs: Query<&NodeCursor>, co: Query<&ChildOf>| {
surface_cursor_for(pointer_id, &hm, &ncs, &co)
},
)
.unwrap();
assert_eq!(icon.as_deref(), Some("pointer"));
}
#[test]
fn surface_pointer_without_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 icon = world
.run_system_once(
move |hm: Res<HoverMap>, ncs: Query<&NodeCursor>, co: Query<&ChildOf>| {
surface_cursor_for(pointer_id, &hm, &ncs, &co)
},
)
.unwrap();
assert_eq!(icon, None);
}
#[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),
);
}
}