feat(engine): pointer cursor on hover over interactive buttons
Previously the cursor stayed the default arrow over every clickable UI element (modal buttons, HUD action bar, mode-launcher cards, settings toggles). Adds the standard "this is clickable" hand affordance: while not dragging a card, hovering any entity with Interaction::Hovered (or Pressed — keeps the pointer through a click-and-hold) sets the window cursor to SystemCursorIcon::Pointer. The new branch sits between the existing drag handlers in update_cursor_icon: Grabbing wins when actively dragging, then Pointer when a button is hovered, then Grab when a draggable card is hovered, then Default. Card-drag affordance unchanged. A pure pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered) helper makes the priority logic unit-testable without standing up a full Window + Camera fixture; four new tests pin every branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,19 @@
|
|||||||
//!
|
//!
|
||||||
//! **Cursor icons** (`update_cursor_icon`)
|
//! **Cursor icons** (`update_cursor_icon`)
|
||||||
//! - Cards are being dragged → `Grabbing` (closed hand)
|
//! - Cards are being dragged → `Grabbing` (closed hand)
|
||||||
|
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
|
||||||
|
//! (the hand-with-extended-index-finger icon). This telegraphs
|
||||||
|
//! clickability for every modal button, HUD action, mode-launcher
|
||||||
|
//! card, settings toggle, etc.
|
||||||
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
|
||||||
//! - Otherwise → `Default` (arrow)
|
//! - Otherwise → `Default` (arrow)
|
||||||
//!
|
//!
|
||||||
|
//! Priority order: dragging > button-hover > card-hover > default. A
|
||||||
|
//! button-overlapping-a-card edge case favours `Pointer` because UI
|
||||||
|
//! elements take precedence over world-space cards; in practice
|
||||||
|
//! buttons are always on UI nodes and cards are sprites, so they
|
||||||
|
//! cannot occupy the same hit region simultaneously.
|
||||||
|
//!
|
||||||
//! **Drop-target highlights** (`update_drop_highlights`)
|
//! **Drop-target highlights** (`update_drop_highlights`)
|
||||||
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
//! While a drag is in progress every `PileMarker` sprite is tinted:
|
||||||
//! - **Green** if the dragged stack can legally land there.
|
//! - **Green** if the dragged stack can legally land there.
|
||||||
@@ -70,6 +80,31 @@ impl Plugin for CursorPlugin {
|
|||||||
// #31 — Cursor icon
|
// #31 — Cursor icon
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure decision function for the cursor icon, separated from the Bevy
|
||||||
|
/// system so it can be unit-tested without `PrimaryWindow` /
|
||||||
|
/// `Camera` / `Time` plumbing.
|
||||||
|
///
|
||||||
|
/// Priority order (highest first):
|
||||||
|
/// 1. `is_dragging` → `Grabbing`
|
||||||
|
/// 2. `any_button_hovered` → `Pointer`
|
||||||
|
/// 3. `any_card_hovered` → `Grab`
|
||||||
|
/// 4. otherwise → `Default`
|
||||||
|
fn pick_cursor_icon(
|
||||||
|
is_dragging: bool,
|
||||||
|
any_button_hovered: bool,
|
||||||
|
any_card_hovered: bool,
|
||||||
|
) -> SystemCursorIcon {
|
||||||
|
if is_dragging {
|
||||||
|
SystemCursorIcon::Grabbing
|
||||||
|
} else if any_button_hovered {
|
||||||
|
SystemCursorIcon::Pointer
|
||||||
|
} else if any_card_hovered {
|
||||||
|
SystemCursorIcon::Grab
|
||||||
|
} else {
|
||||||
|
SystemCursorIcon::Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates the primary-window cursor icon based on drag state and hover.
|
/// Updates the primary-window cursor icon based on drag state and hover.
|
||||||
fn update_cursor_icon(
|
fn update_cursor_icon(
|
||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
@@ -77,18 +112,27 @@ fn update_cursor_icon(
|
|||||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
|
button_q: Query<&Interaction, With<Button>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let Ok((win_entity, window)) = windows.single() else { return };
|
let Ok((win_entity, window)) = windows.single() else { return };
|
||||||
|
|
||||||
if !drag.is_idle() {
|
let is_dragging = !drag.is_idle();
|
||||||
commands
|
|
||||||
.entity(win_entity)
|
|
||||||
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hovering = (|| {
|
// A UI button is "hovered" if any `Button` entity has its
|
||||||
|
// `Interaction` set to `Hovered` or `Pressed`. We include
|
||||||
|
// `Pressed` so the pointer icon stays visible while a click is
|
||||||
|
// being held, matching browser behaviour.
|
||||||
|
let any_button_hovered = button_q
|
||||||
|
.iter()
|
||||||
|
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
|
||||||
|
|
||||||
|
let any_card_hovered = if is_dragging || any_button_hovered {
|
||||||
|
// No need to do the world-space hit test when a higher
|
||||||
|
// priority branch already wins.
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
(|| {
|
||||||
let cursor = window.cursor_position()?;
|
let cursor = window.cursor_position()?;
|
||||||
let (camera, cam_xf) = cameras.single().ok()?;
|
let (camera, cam_xf) = cameras.single().ok()?;
|
||||||
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
|
||||||
@@ -96,13 +140,11 @@ fn update_cursor_icon(
|
|||||||
let game = game.as_ref()?;
|
let game = game.as_ref()?;
|
||||||
Some(cursor_over_draggable(world, &game.0, &layout))
|
Some(cursor_over_draggable(world, &game.0, &layout))
|
||||||
})()
|
})()
|
||||||
.unwrap_or(false);
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
|
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
|
||||||
SystemCursorIcon::Grab
|
commands.entity(win_entity).insert(CursorIcon::from(icon));
|
||||||
} else {
|
|
||||||
SystemCursorIcon::Default
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
|
||||||
@@ -482,6 +524,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// pick_cursor_icon priority-order tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
|
||||||
|
// Dragging always wins regardless of button or card hover state.
|
||||||
|
assert!(matches!(
|
||||||
|
pick_cursor_icon(true, true, true),
|
||||||
|
SystemCursorIcon::Grabbing
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
pick_cursor_icon(true, false, false),
|
||||||
|
SystemCursorIcon::Grabbing
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
|
||||||
|
// Button hover beats card hover when not dragging.
|
||||||
|
assert!(matches!(
|
||||||
|
pick_cursor_icon(false, true, false),
|
||||||
|
SystemCursorIcon::Pointer
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
pick_cursor_icon(false, true, true),
|
||||||
|
SystemCursorIcon::Pointer
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_picks_grab_when_card_hovered_and_no_button() {
|
||||||
|
// Card hover wins only when no drag and no button-hover.
|
||||||
|
assert!(matches!(
|
||||||
|
pick_cursor_icon(false, false, true),
|
||||||
|
SystemCursorIcon::Grab
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_picks_default_when_nothing_hovered() {
|
||||||
|
assert!(matches!(
|
||||||
|
pick_cursor_icon(false, false, false),
|
||||||
|
SystemCursorIcon::Default
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cursor_over_draggable_returns_false_for_empty_game() {
|
fn cursor_over_draggable_returns_false_for_empty_game() {
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
|||||||
Reference in New Issue
Block a user