From a0fc0d2605dc822afafdc4f93b9985fd7e3df38b Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 2 May 2026 20:10:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20keyboard-only=20drag-and-drop?= =?UTF-8?q?=20via=20Tab=20=E2=86=92=20Enter=20=E2=86=92=20arrows=20?= =?UTF-8?q?=E2=86=92=20Enter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- solitaire_engine/src/help_plugin.rs | 11 + solitaire_engine/src/lib.rs | 4 +- solitaire_engine/src/onboarding_plugin.rs | 1 + solitaire_engine/src/selection_plugin.rs | 911 +++++++++++++++++++--- 4 files changed, 829 insertions(+), 98 deletions(-) diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 3368dde..2a18018 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -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: &[ diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index cda387b..1f78e15 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -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}; diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index 2e15b2a..e8eda8a 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -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 1–5 to pick)" }, HotkeyRow { keys: "S", description: "Stats & progression" }, diff --git a/solitaire_engine/src/selection_plugin.rs b/solitaire_engine/src/selection_plugin.rs index c311b15..60d9fce 100644 --- a/solitaire_engine/src/selection_plugin.rs +++ b/solitaire_engine/src/selection_plugin.rs @@ -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, } +/// 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, + /// 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, + /// 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::() + .init_resource::() .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>, paused: Option>, game: Res, mut selection: ResMut, + mut kbd_drag: ResMut, + mut drag: ResMut, mut moves: MessageWriter, mut info_toast: MessageWriter, ) { @@ -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 = { let all = [ @@ -222,71 +406,159 @@ 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 - .piles - .get(pile) - .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 { - moves.write(MoveRequestEvent { - from: pile.clone(), - to: dest, - count: 1, - }); - selection.selected_pile = None; - return; - } + && let Some(card) = game + .0 + .piles + .get(pile) + .and_then(|p| p.cards.last()) + .filter(|c| c.face_up) + { + // Priority 1: foundation move (single card). + if let Some(dest) = try_foundation_dest(card, &game.0) { + moves.write(MoveRequestEvent { + from: pile.clone(), + to: dest, + count: 1, + }); + selection.selected_pile = None; + return; + } + // 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) + }); + if let Some(bottom) = bottom_card + && let Some((dest, count)) = + best_tableau_destination_for_stack(bottom, pile, &game.0, run_len) + { + moves.write(MoveRequestEvent { + from: pile.clone(), + to: dest, + count, + }); + selection.selected_pile = None; + return; + } + // Fallback for non-tableau sources. + if let Some(dest) = best_destination(card, &game.0) { + moves.write(MoveRequestEvent { + from: pile.clone(), + to: dest, + count: 1, + }); + 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| { - let start = p.cards.len().saturating_sub(run_len); - p.cards.get(start) - }); - if let Some(bottom) = bottom_card - && let Some((dest, count)) = - best_tableau_destination_for_stack(bottom, pile, &game.0, run_len) - { - moves.write(MoveRequestEvent { - from: pile.clone(), - to: dest, - count, - }); - 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 = + 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; + } - // --- Fallback: single-card move to any destination --- - // Covers non-tableau sources (Waste, Foundation) that have no - // stack-move logic. - if let Some(dest) = best_destination(card, &game.0) { - moves.write(MoveRequestEvent { - from: pile.clone(), - to: dest, - count: 1, - }); - selection.selected_pile = None; - } + // 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 { + 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, mut selection: ResMut, + mut kbd_drag: ResMut, + mut drag: ResMut, ) { 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, + kbd_drag: Res, game: Res, layout: Option>, 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 = 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::(); + app.add_message::(); + app.add_message::(); + app.init_resource::(); + app.init_resource::>(); + 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::>(); + input.release(key); + input.clear(); + input.press(key); + } + + fn clear_input(app: &mut App) { + app.world_mut() + .resource_mut::>() + .clear(); + } + + fn collect_move_events(app: &mut App) -> Vec { + let events = app.world().resource::>(); + 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::().selected_pile.is_none()); + assert_eq!(*app.world().resource::(), KeyboardDragState::Idle); + + press_key(&mut app, KeyCode::Tab); + app.update(); + + let selected = app.world().resource::().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::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::().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::().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::(); + 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::().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 = match app.world().resource::() { + 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::().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::().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::().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::().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::().selected_pile = + Some(PileType::Tableau(0)); + press_key(&mut app, KeyCode::Enter); + app.update(); + + // Sanity: lifted with a focused destination. + assert!(app.world().resource::().is_lifted()); + let expected_dest = app + .world() + .resource::() + .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::Idle, + "Enter on lifted must return state machine to Idle", + ); + assert!( + app.world().resource::().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::().selected_pile = + Some(PileType::Tableau(0)); + press_key(&mut app, KeyCode::Enter); + app.update(); + assert!(app.world().resource::().is_lifted()); + + // Esc cancels. + clear_input(&mut app); + press_key(&mut app, KeyCode::Escape); + app.update(); + + assert_eq!( + *app.world().resource::(), + KeyboardDragState::Idle, + "Esc on lifted must return state machine to Idle", + ); + assert_eq!( + app.world().resource::().selected_pile, + Some(PileType::Tableau(0)), + "Esc on lifted must keep SelectionState intact (source-pick mode)", + ); + assert!( + app.world().resource::().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::(); + drag.cards = vec![100]; + drag.origin_pile = Some(PileType::Tableau(0)); + drag.committed = true; + drag.active_touch_id = None; + } + + let before = app.world().resource::().selected_pile.clone(); + press_key(&mut app, KeyCode::Tab); + app.update(); + let after = app.world().resource::().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::().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::().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::().selected_pile.is_none(), + "second Esc clears the source selection", + ); + } }