feat(engine): keyboard-only drag-and-drop via Tab → Enter → arrows → Enter

Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.

A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:

  Idle                         (Tab/Enter/auto-move via SelectionState)
  Lifted {
      source_pile, count, cards,
      legal_destinations,      pre-computed at lift time via
      destination_index,       can_place_on_foundation/_tableau
  }

Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.

The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.

help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.

Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-02 20:10:41 +00:00
parent 7ed4f2cba9
commit a0fc0d2605
4 changed files with 829 additions and 98 deletions
+11
View File
@@ -94,6 +94,17 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlRow { keys: "Click stock", description: "Draw" },
],
},
ControlSection {
title: "Keyboard drag",
rows: &[
ControlRow { keys: "Tab", description: "Focus next draggable card" },
ControlRow { keys: "Enter", description: "Lift focused card (then arrows pick where)" },
ControlRow { keys: "Arrows / Tab", description: "Cycle legal destinations while lifted" },
ControlRow { keys: "Enter", description: "Drop the lifted cards on the focused pile" },
ControlRow { keys: "Esc", description: "Cancel lift (Esc again clears focus)" },
ControlRow { keys: "Space", description: "Auto-move focused card (foundation first)" },
],
},
ControlSection {
title: "New Game",
rows: &[
+3 -1
View File
@@ -110,7 +110,9 @@ pub use settings_plugin::{
};
pub use layout::{compute_layout, Layout, LayoutResource};
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
@@ -93,6 +93,7 @@ struct HotkeyRow {
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
HotkeyRow { keys: "U", description: "Undo last move" },
HotkeyRow { keys: "Tab → Enter", description: "Pick a card; arrows pick where; Enter to drop" },
HotkeyRow { keys: "N", description: "New Classic game" },
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 15 to pick)" },
HotkeyRow { keys: "S", description: "Stats & progression" },
+778 -61
View File
@@ -1,24 +1,45 @@
//! Keyboard-driven card selection (Task #68).
//! Keyboard-driven card selection and full keyboard drag-and-drop.
//!
//! Pressing `Tab` cycles through piles that have a face-up draggable top card.
//! Pressing `Enter` or `Space` fires a [`MoveRequestEvent`] to the best
//! available destination using the following priority order, then clears the
//! selection:
//! ## Two-mode flow
//!
//! 1. Move the top card to its best foundation (count = 1).
//! 2. Move the full face-up run from the selected tableau pile to the best
//! tableau destination (count = run length). Single-card stacks from
//! non-tableau piles fall back to [`best_destination`] for tableau targets.
//! Selection works as a small state machine across two resources:
//!
//! Pressing `Escape` clears the selection without moving.
//! 1. [`SelectionState`] tracks the *source-pick* mode. `Tab` / `Shift+Tab`
//! cycles a focus through piles that have a face-up draggable top card.
//! The focused card is decorated with a cyan [`SelectionHighlight`].
//!
//! The selected card is highlighted by a cyan [`SelectionHighlight`] outline
//! sprite parented to the selected card entity. The highlight is despawned when
//! the selection is cleared.
//! 2. [`KeyboardDragState`] tracks the *destination-pick* mode. Pressing
//! `Enter` while a pile is focused enters
//! [`KeyboardDragState::Lifted`] — the cards are visually "lifted" by
//! populating [`crate::resources::DragState`] (cards / origin_pile /
//! cursor_offset / origin_z / `active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID)`
//! sentinel so mouse handlers ignore the keyboard-driven drag), and the
//! arrow keys (or `Tab` / `Shift+Tab`) cycle through *legal* destination
//! piles only. A second `Enter` confirms the move; `Esc` cancels back to
//! source-pick mode.
//!
//! ## Mutual exclusion with mouse drag
//!
//! While a mouse drag is in progress (`DragState` non-empty *and* not the
//! keyboard sentinel) all keyboard input is ignored. Conversely, while the
//! keyboard drag is active, mouse handlers in `input_plugin` short-circuit
//! because they check `DragState.is_idle()` before starting a new drag and
//! the mouse-up / drag-update systems explicitly skip `DragState` entries
//! whose `active_touch_id.is_some()`.
//!
//! ## Why a separate resource
//!
//! Keeping the lift state out of `SelectionState` lets `Esc` cancel the
//! lift without losing the source focus — a single Esc reverts to
//! source-pick, a second Esc clears the source focus. It also lets HUD
//! widgets that already read `SelectionState::selected_pile` keep working
//! unchanged whether the player is in source-pick or destination-pick mode.
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_plugin::CardEntity;
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
@@ -26,7 +47,7 @@ use crate::game_plugin::GameMutation;
use crate::input_plugin::{best_destination, best_tableau_destination_for_stack};
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource;
use crate::resources::{DragState, GameStateResource};
// ---------------------------------------------------------------------------
// Public types
@@ -41,6 +62,70 @@ pub struct SelectionState {
pub selected_pile: Option<PileType>,
}
/// Sentinel value used in [`crate::resources::DragState::active_touch_id`]
/// to mark a `DragState` populated by the keyboard-drag flow rather than a
/// real mouse or touch drag.
///
/// Mouse handlers in `input_plugin` already skip `DragState` entries whose
/// `active_touch_id.is_some()`, so this value provides clean mutual
/// exclusion without changing `DragState`'s shape.
pub const KEYBOARD_DRAG_TOUCH_ID: u64 = u64::MAX;
/// Two-state machine for the keyboard drag flow. `Idle` is the resting
/// state; while `Lifted`, the player is choosing a destination pile with
/// the arrow keys.
///
/// See the [module-level docs](self) for the full state machine.
#[derive(Resource, Debug, Default, Clone, PartialEq, Eq)]
pub enum KeyboardDragState {
/// No keyboard drag in progress. `Tab` / `Enter` operate on
/// [`SelectionState`].
#[default]
Idle,
/// Source pile is lifted; arrow keys / `Tab` cycle through
/// `legal_destinations` and `Enter` fires the move.
Lifted {
/// Pile the cards were lifted from.
source_pile: PileType,
/// Number of cards lifted (1 for waste / foundation, full face-up
/// run length for a tableau column).
count: usize,
/// Card ids being lifted, in the same bottom-to-top order
/// `DragState.cards` expects.
cards: Vec<u32>,
/// Pre-computed list of piles the lifted stack can legally be
/// placed on. Always at least one entry while in this variant —
/// if no legal destinations exist the state machine refuses to
/// enter `Lifted` in the first place.
legal_destinations: Vec<PileType>,
/// Cursor into `legal_destinations`. Always `< legal_destinations.len()`.
destination_index: usize,
},
}
impl KeyboardDragState {
/// Returns the currently focused destination pile while [`Lifted`], or
/// `None` while [`Idle`].
///
/// [`Lifted`]: KeyboardDragState::Lifted
/// [`Idle`]: KeyboardDragState::Idle
pub fn focused_destination(&self) -> Option<&PileType> {
match self {
Self::Idle => None,
Self::Lifted {
legal_destinations,
destination_index,
..
} => legal_destinations.get(*destination_index),
}
}
/// Returns `true` when the keyboard drag is in the `Lifted` state.
pub fn is_lifted(&self) -> bool {
matches!(self, Self::Lifted { .. })
}
}
/// System set label for the key-handling system.
///
/// `PausePlugin` registers `toggle_pause` before this set so it can read
@@ -62,6 +147,7 @@ pub struct SelectionPlugin;
impl Plugin for SelectionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SelectionState>()
.init_resource::<KeyboardDragState>()
.add_systems(
Update,
(
@@ -161,13 +247,33 @@ fn did_wrap(
// Systems
// ---------------------------------------------------------------------------
/// Handles Tab / Enter / Space / Escape for keyboard card selection.
/// Handles `Tab` / `Enter` / `Space` / arrow keys / `Escape` for keyboard
/// card selection and keyboard drag-and-drop.
///
/// Source-pick mode (`KeyboardDragState::Idle`):
/// - `Tab` / `Shift+Tab` cycles `SelectionState` through draggable piles.
/// - `Enter` lifts the focused pile into `KeyboardDragState::Lifted`.
/// - `Space` is the legacy auto-move accelerator (foundation-first, then
/// best tableau target). Preserved so power users keep their muscle
/// memory; the new lift-and-pick flow is what `Enter` does.
/// - `Esc` clears `SelectionState`.
///
/// Destination-pick mode (`KeyboardDragState::Lifted`):
/// - `ArrowRight` / `ArrowDown` / `Tab` advance to the next legal
/// destination, wrapping at the end.
/// - `ArrowLeft` / `ArrowUp` / `Shift+Tab` move to the previous legal
/// destination.
/// - `Enter` confirms — fires `MoveRequestEvent` and returns to `Idle`.
/// - `Esc` cancels — clears the `DragState` and returns to source-pick
/// mode with `SelectionState` intact.
#[allow(clippy::too_many_arguments)]
fn handle_selection_keys(
keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>,
game: Res<GameStateResource>,
mut selection: ResMut<SelectionState>,
mut kbd_drag: ResMut<KeyboardDragState>,
mut drag: ResMut<DragState>,
mut moves: MessageWriter<MoveRequestEvent>,
mut info_toast: MessageWriter<InfoToastEvent>,
) {
@@ -175,6 +281,84 @@ fn handle_selection_keys(
return;
}
// Mutual exclusion with mouse drag — if a real mouse / touch drag is
// running, swallow keyboard input. The keyboard-driven lift uses the
// sentinel `active_touch_id`, so only that case may proceed.
if !drag.is_idle() && drag.active_touch_id != Some(KEYBOARD_DRAG_TOUCH_ID) {
return;
}
// ---------------------------------------------------------------------
// Lifted (destination-pick) mode.
// ---------------------------------------------------------------------
if let KeyboardDragState::Lifted {
source_pile,
count,
cards: _,
legal_destinations,
destination_index,
} = &mut *kbd_drag
{
let shift_held =
keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
// Cycle destinations forward / backward.
let advance = keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::ArrowDown)
|| (keys.just_pressed(KeyCode::Tab) && !shift_held);
let retreat = keys.just_pressed(KeyCode::ArrowLeft)
|| keys.just_pressed(KeyCode::ArrowUp)
|| (keys.just_pressed(KeyCode::Tab) && shift_held);
if advance {
let n = legal_destinations.len();
if n > 0 {
*destination_index = (*destination_index + 1) % n;
}
return;
}
if retreat {
let n = legal_destinations.len();
if n > 0 {
*destination_index = (*destination_index + n - 1) % n;
}
return;
}
// Confirm — fire MoveRequestEvent.
if keys.just_pressed(KeyCode::Enter) {
if let Some(dest) = legal_destinations.get(*destination_index).cloned() {
moves.write(MoveRequestEvent {
from: source_pile.clone(),
to: dest,
count: *count,
});
}
// Whether or not we fired, leave Lifted: a subsequent
// `StateChangedEvent` will also reset us via
// `clear_selection_on_state_change`, but explicit reset is
// cleaner and lets the state-change clear handle the
// SelectionState side.
*kbd_drag = KeyboardDragState::Idle;
drag.clear();
return;
}
// Cancel back to source-pick mode — keep SelectionState focused.
if keys.just_pressed(KeyCode::Escape) {
*kbd_drag = KeyboardDragState::Idle;
drag.clear();
return;
}
// No other keys do anything while lifted.
return;
}
// ---------------------------------------------------------------------
// Idle (source-pick) mode.
// ---------------------------------------------------------------------
// Build the list of piles that currently have a face-up draggable top card.
let available: Vec<PileType> = {
let all = [
@@ -222,15 +406,10 @@ fn handle_selection_keys(
return;
}
// Enter / Space — execute move for the selected pile's top card (or full
// face-up run when the source is a tableau column).
//
// Priority:
// 1. Foundation move — always count = 1.
// 2. Tableau stack move — count = full face-up run length from the source.
let activate =
keys.just_pressed(KeyCode::Enter) || keys.just_pressed(KeyCode::Space);
if activate
// Space — legacy auto-move accelerator. Foundation-first, then best
// tableau stack target. Preserved so the muscle memory built around
// `Tab` → `Space` keeps working; `Enter` is now the lift trigger.
if keys.just_pressed(KeyCode::Space)
&& let Some(ref pile) = selection.selected_pile.clone()
&& let Some(card) = game
.0
@@ -239,9 +418,8 @@ fn handle_selection_keys(
.and_then(|p| p.cards.last())
.filter(|c| c.face_up)
{
// --- Priority 1: foundation move (single card) ---
let foundation_dest = try_foundation_dest(card, &game.0);
if let Some(dest) = foundation_dest {
// Priority 1: foundation move (single card).
if let Some(dest) = try_foundation_dest(card, &game.0) {
moves.write(MoveRequestEvent {
from: pile.clone(),
to: dest,
@@ -250,15 +428,11 @@ fn handle_selection_keys(
selection.selected_pile = None;
return;
}
// --- Priority 2: tableau stack move ---
// Count the full contiguous face-up run in the source pile.
let run_len = face_up_run_len(game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()));
let bottom_card = game
.0
.piles
.get(pile)
.and_then(|p| {
// Priority 2: tableau stack move.
let run_len = face_up_run_len(
game.0.piles.get(pile).map_or(&[], |p| p.cards.as_slice()),
);
let bottom_card = game.0.piles.get(pile).and_then(|p| {
let start = p.cards.len().saturating_sub(run_len);
p.cards.get(start)
});
@@ -274,10 +448,7 @@ fn handle_selection_keys(
selection.selected_pile = None;
return;
}
// --- Fallback: single-card move to any destination ---
// Covers non-tableau sources (Waste, Foundation) that have no
// stack-move logic.
// Fallback for non-tableau sources.
if let Some(dest) = best_destination(card, &game.0) {
moves.write(MoveRequestEvent {
from: pile.clone(),
@@ -286,7 +457,108 @@ fn handle_selection_keys(
});
selection.selected_pile = None;
}
return;
}
// Enter — lift the focused pile into destination-pick mode.
if keys.just_pressed(KeyCode::Enter)
&& let Some(ref source) = selection.selected_pile.clone()
{
let Some(pile_cards) = game.0.piles.get(source) else {
return;
};
// Determine the lift range: tableau lifts the full face-up run, all
// other sources lift only the top card.
let run_len = face_up_run_len(pile_cards.cards.as_slice());
let count = if matches!(source, PileType::Tableau(_)) {
run_len.max(1)
} else {
1
};
if pile_cards.cards.is_empty() {
return;
}
let start = pile_cards.cards.len().saturating_sub(count);
let lifted_cards: Vec<u32> =
pile_cards.cards[start..].iter().map(|c| c.id).collect();
let Some(bottom) = pile_cards.cards.get(start) else {
return;
};
let legal = legal_destinations_for(bottom, source, &game.0, count);
if legal.is_empty() {
info_toast.write(InfoToastEvent(
"No legal moves for this card".to_string(),
));
return;
}
// Populate `DragState` with the keyboard sentinel so the existing
// mouse-drag systems treat this as "not their drag".
drag.cards = lifted_cards.clone();
drag.origin_pile = Some(source.clone());
drag.cursor_offset = Vec2::ZERO;
drag.origin_z = 1.0;
drag.press_pos = Vec2::ZERO;
drag.committed = false;
drag.active_touch_id = Some(KEYBOARD_DRAG_TOUCH_ID);
*kbd_drag = KeyboardDragState::Lifted {
source_pile: source.clone(),
count,
cards: lifted_cards,
legal_destinations: legal,
destination_index: 0,
};
}
}
// ---------------------------------------------------------------------------
// Legal-destination enumeration
// ---------------------------------------------------------------------------
/// Enumerate every pile that the lifted stack rooted at `bottom` can be
/// legally placed on, excluding the source pile itself.
///
/// Foundations are returned first (in slot order 0..4), then tableau
/// columns (in column order 0..7). Foundations only accept single-card
/// stacks, matching the existing rules engine.
///
/// The order is deliberate: the first entry is the most "obvious" target
/// (the lowest foundation or column number) which becomes the default
/// destination after a lift. Players who want a different column simply
/// press the right-arrow key once or twice.
pub(crate) fn legal_destinations_for(
bottom: &solitaire_core::card::Card,
source: &PileType,
game: &GameState,
stack_count: usize,
) -> Vec<PileType> {
let mut out = Vec::new();
if stack_count == 1 {
for slot in 0..4_u8 {
let dest = PileType::Foundation(slot);
if &dest == source {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_foundation(bottom, pile)
{
out.push(dest);
}
}
}
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if &dest == source {
continue;
}
if let Some(pile) = game.piles.get(&dest)
&& can_place_on_tableau(bottom, pile)
{
out.push(dest);
}
}
out
}
// ---------------------------------------------------------------------------
@@ -336,22 +608,44 @@ fn try_foundation_dest(
/// Without this, an undo or a rejected move could leave `selected_pile`
/// pointing at a pile whose top card changed, causing the highlight to
/// trail a different card than the player expects.
///
/// Also resets [`KeyboardDragState`] back to `Idle` and clears any
/// keyboard-driven [`DragState`] population — the lifted cards have just
/// moved (or been undone) so the cached `legal_destinations` are stale.
fn clear_selection_on_state_change(
mut state_events: MessageReader<StateChangedEvent>,
mut selection: ResMut<SelectionState>,
mut kbd_drag: ResMut<KeyboardDragState>,
mut drag: ResMut<DragState>,
) {
if state_events.read().next().is_some() {
selection.selected_pile = None;
if matches!(*kbd_drag, KeyboardDragState::Lifted { .. }) {
*kbd_drag = KeyboardDragState::Idle;
// Only clear DragState if it's the keyboard sentinel — never
// stomp a real mouse / touch drag.
if drag.active_touch_id == Some(KEYBOARD_DRAG_TOUCH_ID) {
drag.clear();
}
}
}
}
/// Maintains the `SelectionHighlight` outline sprite.
///
/// When a pile is selected, a cyan sprite is placed at the selected card's
/// position. When the selection is cleared the highlight entity is despawned.
/// When a pile is selected (source-pick mode), a cyan sprite is placed
/// at the selected card's position. While
/// [`KeyboardDragState::Lifted`] the source highlight tints gold and a
/// second highlight follows the focused destination's top card — visually
/// telling the player "these cards will move to that pile when you press
/// Enter".
///
/// All highlights are despawned and respawned every frame so an undo /
/// rejected move can never leave a stale outline behind.
fn update_selection_highlight(
mut commands: Commands,
selection: Res<SelectionState>,
kbd_drag: Res<KeyboardDragState>,
game: Res<GameStateResource>,
layout: Option<Res<LayoutResource>>,
card_entities: Query<(Entity, &CardEntity)>,
@@ -361,40 +655,90 @@ fn update_selection_highlight(
for entity in &highlights {
commands.entity(entity).despawn();
}
let Some(ref pile) = selection.selected_pile else {
return;
};
let Some(layout) = layout else {
return;
};
let Some(card) = game
.0
.piles
let card_size = layout.0.card_size;
// Choose colours per mode: cyan in source-pick, gold while lifted.
let lifted = kbd_drag.is_lifted();
let source_color = if lifted {
Color::srgba(1.0, 0.84, 0.0, 0.6)
} else {
Color::srgba(0.0, 1.0, 1.0, 0.5)
};
let dest_color = Color::srgba(0.0, 1.0, 0.4, 0.6);
// Resolve the source pile from KeyboardDragState (when lifted) or
// SelectionState (otherwise). Lifted takes precedence so the gold
// outline follows the actual lifted cards.
let source_pile: Option<PileType> = match &*kbd_drag {
KeyboardDragState::Lifted { source_pile, .. } => Some(source_pile.clone()),
KeyboardDragState::Idle => selection.selected_pile.clone(),
};
if let Some(ref pile) = source_pile
&& let Some(card) = top_face_up_card(pile, &game.0)
{
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
card_size,
source_color,
);
}
// Destination highlight while lifted.
if let Some(dest) = kbd_drag.focused_destination() {
// For non-empty piles, anchor on the top card. For empty piles
// (e.g. an empty tableau column), no card exists to anchor to;
// skip — the source highlight already conveys that the player is
// in destination-pick mode and the focused index is observable
// via the resource.
if let Some(card) = top_face_up_card(dest, &game.0) {
spawn_highlight_on_card(
&mut commands,
&card_entities,
card.id,
card_size,
dest_color,
);
}
}
}
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card<'a>(
pile: &PileType,
game: &'a GameState,
) -> Option<&'a solitaire_core::card::Card> {
game.piles
.get(pile)
.and_then(|p| p.cards.last())
.filter(|c| c.face_up)
else {
return;
};
}
let card_id = card.id;
let card_size = layout.0.card_size;
// Find the entity for the selected card so we can read its position.
for (entity, card_entity) in &card_entities {
/// Spawn a `SelectionHighlight` sprite as a child of the entity carrying
/// the matching `CardEntity::card_id`. No-op if no entity matches.
fn spawn_highlight_on_card(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card_size: Vec2,
color: Color,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id {
// Spawn the highlight as a child of the card entity so it moves
// with it automatically.
commands.entity(entity).with_children(|b| {
b.spawn((
SelectionHighlight,
Sprite {
color: Color::srgba(0.0, 1.0, 1.0, 0.5),
color,
custom_size: Some(card_size + Vec2::splat(4.0)),
..default()
},
// Slightly behind the card face so text labels are still visible.
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
@@ -552,4 +896,377 @@ mod tests {
];
assert_eq!(face_up_run_len(&cards), 1);
}
// -----------------------------------------------------------------------
// Keyboard drag-and-drop — full integration tests
//
// Each test runs a `MinimalPlugins` Bevy app with `SelectionPlugin` and
// builds a deterministic `GameState` so the legal-destination ordering
// is predictable without depending on the deal RNG.
// -----------------------------------------------------------------------
use bevy::ecs::message::Messages;
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
/// Build a minimal app with `SelectionPlugin` only — no GamePlugin, no
/// AssetServer. The `MoveRequestEvent` / `StateChangedEvent` /
/// `InfoToastEvent` channels are registered manually so the plugin's
/// systems compile and run.
fn drag_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<MoveRequestEvent>();
app.add_message::<StateChangedEvent>();
app.add_message::<InfoToastEvent>();
app.init_resource::<DragState>();
app.init_resource::<ButtonInput<KeyCode>>();
app.add_plugins(SelectionPlugin);
app
}
/// Build a tableau-only board with deterministic top cards so the
/// keyboard-cycle order is predictable.
///
/// Layout:
/// - Tableau(0): 5♣ face-up (red destinations: 4♥ on T1 face-up below)
/// - Tableau(1): 6♥ face-up
/// - Tableau(2): 6♦ face-up
/// - Tableau(3..7): empty
/// - Stock / Waste / Foundations: empty
///
/// 5♣ on T0 can legally go to either 6♥ on T1 or 6♦ on T2 (both red,
/// rank one higher). It cannot go to a foundation (Foundation needs
/// Ace first). It cannot go to an empty tableau (only Kings).
/// Empty tableaus T3..T6 only accept Kings, so they are filtered out.
fn deterministic_state() -> GameState {
let mut g = GameState::new(0, DrawMode::DrawOne);
// Clear stock, waste, all tableaus.
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 {
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
}
// Place test cards.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
});
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards.push(Card {
id: 101,
suit: Suit::Hearts,
rank: Rank::Six,
face_up: true,
});
g.piles.get_mut(&PileType::Tableau(2)).unwrap().cards.push(Card {
id: 102,
suit: Suit::Diamonds,
rank: Rank::Six,
face_up: true,
});
g
}
fn install_state(app: &mut App, state: GameState) {
app.insert_resource(GameStateResource(state));
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
fn clear_input(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
}
fn collect_move_events(app: &mut App) -> Vec<MoveRequestEvent> {
let events = app.world().resource::<Messages<MoveRequestEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).cloned().collect()
}
/// Test 1 — Tab in idle state cycles to the first draggable pile.
///
/// On the deterministic board, the first draggable pile in cycle order
/// is `Tableau(0)` (the 5♣).
#[test]
fn tab_in_idle_cycles_to_first_draggable_pile() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
// Initial state: nothing selected, KeyboardDragState::Idle.
assert!(app.world().resource::<SelectionState>().selected_pile.is_none());
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
press_key(&mut app, KeyCode::Tab);
app.update();
let selected = app.world().resource::<SelectionState>().selected_pile.clone();
// The cycle order starts at Waste, but Waste is empty so the next
// available pile (Tableau(0)) is selected.
assert_eq!(selected, Some(PileType::Tableau(0)));
assert_eq!(*app.world().resource::<KeyboardDragState>(), KeyboardDragState::Idle);
}
/// Test 2 — Enter while a source is selected lifts the stack.
///
/// `DragState.cards` must be populated with the lifted card ids and the
/// keyboard sentinel must be set.
#[test]
fn enter_in_source_selected_lifts_the_stack() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
// Manually focus Tableau(0) so we don't depend on Tab.
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Assert KeyboardDragState is Lifted with the right metadata.
let kbd = app.world().resource::<KeyboardDragState>().clone();
match kbd {
KeyboardDragState::Lifted {
source_pile,
count,
cards,
legal_destinations,
destination_index,
} => {
assert_eq!(source_pile, PileType::Tableau(0));
assert_eq!(count, 1);
assert_eq!(cards, vec![100]);
assert!(
!legal_destinations.is_empty(),
"lifted stack must have at least one legal destination"
);
assert_eq!(destination_index, 0);
}
other => panic!("expected Lifted, got {other:?}"),
}
// DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]);
assert_eq!(drag.origin_pile, Some(PileType::Tableau(0)));
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
}
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
/// only (foundations and tableaus that pass `can_place_on_*`), and
/// wrap at the end of the list.
#[test]
fn arrow_in_lifted_cycles_legal_destinations_only() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Capture the destination list. For the deterministic state the 5♣
// (black) can land on 6♥ (T1) or 6♦ (T2) — both red, rank one
// higher. Verify that the destinations are exactly those tableaus
// (in cycle order T1 then T2).
let initial_dests: Vec<PileType> = match app.world().resource::<KeyboardDragState>() {
KeyboardDragState::Lifted { legal_destinations, .. } => legal_destinations.clone(),
_ => panic!("expected Lifted"),
};
assert_eq!(
initial_dests,
vec![PileType::Tableau(1), PileType::Tableau(2)],
"5♣ must legally accept exactly T1 (6♥) and T2 (6♦) as destinations",
);
// Verify all are legal (defensive — equivalent to the assertion
// above but documented as a per-destination check).
for dest in &initial_dests {
let bottom_card = Card {
id: 100,
suit: Suit::Clubs,
rank: Rank::Five,
face_up: true,
};
let pile = app.world().resource::<GameStateResource>().0.piles.get(dest).unwrap().clone();
assert!(
can_place_on_tableau(&bottom_card, &pile),
"destination {dest:?} must be legal for the lifted stack",
);
}
// Initial focused destination = first entry.
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
Some(&PileType::Tableau(1)),
);
// ArrowRight → next.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
Some(&PileType::Tableau(2)),
);
// ArrowRight again → wraps to first.
clear_input(&mut app);
press_key(&mut app, KeyCode::ArrowRight);
app.update();
assert_eq!(
app.world().resource::<KeyboardDragState>().focused_destination(),
Some(&PileType::Tableau(1)),
"destination index must wrap back to 0 after exhausting the list",
);
}
/// Test 4 — Enter while `Lifted` with a destination focused fires
/// exactly one `MoveRequestEvent` and resets the state machine to
/// `Idle` with `DragState` cleared.
#[test]
fn enter_in_lifted_with_destination_fires_move_request_event() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
// Sanity: lifted with a focused destination.
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
let expected_dest = app
.world()
.resource::<KeyboardDragState>()
.focused_destination()
.cloned()
.expect("must have a focused destination after lift");
// Confirm with Enter.
clear_input(&mut app);
press_key(&mut app, KeyCode::Enter);
app.update();
let events = collect_move_events(&mut app);
assert_eq!(events.len(), 1, "exactly one MoveRequestEvent must fire");
assert_eq!(events[0].from, PileType::Tableau(0));
assert_eq!(events[0].to, expected_dest);
assert_eq!(events[0].count, 1);
// State machine resets.
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle,
"Enter on lifted must return state machine to Idle",
);
assert!(
app.world().resource::<DragState>().is_idle(),
"DragState must be cleared after confirming the move",
);
}
/// Test 5 — Esc while `Lifted` cancels back to source-selected with
/// `SelectionState` intact and `DragState` cleared.
#[test]
fn escape_in_lifted_returns_to_source_selected() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
assert!(app.world().resource::<KeyboardDragState>().is_lifted());
// Esc cancels.
clear_input(&mut app);
press_key(&mut app, KeyCode::Escape);
app.update();
assert_eq!(
*app.world().resource::<KeyboardDragState>(),
KeyboardDragState::Idle,
"Esc on lifted must return state machine to Idle",
);
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)),
"Esc on lifted must keep SelectionState intact (source-pick mode)",
);
assert!(
app.world().resource::<DragState>().is_idle(),
"DragState must be cleared after cancelling the lift",
);
}
/// Mouse drag in progress (non-keyboard `active_touch_id`) must
/// suppress keyboard input — pressing Tab while a real mouse drag is
/// running must not change `SelectionState`.
#[test]
fn keyboard_input_ignored_while_mouse_drag_active() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
// Simulate a real mouse drag by populating DragState without the
// keyboard sentinel.
{
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![100];
drag.origin_pile = Some(PileType::Tableau(0));
drag.committed = true;
drag.active_touch_id = None;
}
let before = app.world().resource::<SelectionState>().selected_pile.clone();
press_key(&mut app, KeyCode::Tab);
app.update();
let after = app.world().resource::<SelectionState>().selected_pile.clone();
assert_eq!(
before, after,
"Tab must not change SelectionState while a mouse drag is in progress",
);
}
/// Esc on a lifted state with no prior state-change does NOT clear
/// `SelectionState`. A second Esc (now that the state is Idle) does.
#[test]
fn double_escape_clears_source_selection() {
let mut app = drag_test_app();
install_state(&mut app, deterministic_state());
app.update();
app.world_mut().resource_mut::<SelectionState>().selected_pile =
Some(PileType::Tableau(0));
press_key(&mut app, KeyCode::Enter);
app.update();
clear_input(&mut app);
press_key(&mut app, KeyCode::Escape);
app.update();
assert_eq!(
app.world().resource::<SelectionState>().selected_pile,
Some(PileType::Tableau(0)),
"first Esc only cancels the lift",
);
clear_input(&mut app);
press_key(&mut app, KeyCode::Escape);
app.update();
assert!(
app.world().resource::<SelectionState>().selected_pile.is_none(),
"second Esc clears the source selection",
);
}
}