Skip to main content

ftui_widgets/
keyboard_drag.rs

1#![forbid(unsafe_code)]
2
3//! Keyboard-driven drag-and-drop support (bd-1csc.4).
4//!
5//! This module enables drag operations via keyboard for accessibility, complementing
6//! the mouse-based drag protocol in [`crate::drag`].
7//!
8//! # Usage
9//!
10//! ```ignore
11//! use ftui_widgets::keyboard_drag::{KeyboardDragManager, KeyboardDragConfig};
12//! use ftui_widgets::drag::DragPayload;
13//!
14//! let config = KeyboardDragConfig::default();
15//! let mut manager = KeyboardDragManager::new(config);
16//!
17//! // User picks up an item (Space/Enter on a draggable)
18//! manager.start_drag(source_id, payload);
19//!
20//! // User navigates targets (Arrow keys)
21//! manager.navigate_targets(Direction::Right, &available_targets);
22//!
23//! // User drops (Space/Enter) or cancels (Escape)
24//! if let Some(result) = manager.complete_drag(drop_target) {
25//!     // Handle drop result
26//! }
27//! ```
28//!
29//! # Invariants
30//!
31//! 1. A keyboard drag is either `Inactive`, `Holding`, or `Navigating`:
32//!    - `Inactive`: No drag in progress
33//!    - `Holding`: Item picked up, awaiting target navigation
34//!    - `Navigating`: Actively navigating between drop targets
35//! 2. `start_drag` can only be called in `Inactive` mode.
36//! 3. `navigate_targets` can only be called in `Holding` or `Navigating` mode.
37//! 4. `complete_drag` transitions to `Inactive` regardless of success/failure.
38//! 5. `cancel_drag` always transitions to `Inactive`.
39//!
40//! # Failure Modes
41//!
42//! | Failure | Cause | Fallback |
43//! |---------|-------|----------|
44//! | No valid targets | All targets reject payload type | Stay in Holding mode |
45//! | Focus loss | Window deactivated | Auto-cancel drag |
46//! | Invalid source | Source widget destroyed | Cancel drag gracefully |
47//!
48//! # Accessibility
49//!
50//! The module supports screen reader announcements via [`Announcement`] queue:
51//! - "Picked up: {item description}"
52//! - "Drop target: {target name} ({position})"
53//! - "Dropped on: {target name}" or "Drop cancelled"
54
55use crate::drag::DragPayload;
56use crate::measure_cache::WidgetId;
57use ftui_core::geometry::Rect;
58use ftui_render::cell::CellContent;
59use ftui_render::cell::PackedRgba;
60use ftui_render::frame::Frame;
61use ftui_style::Style;
62
63// ---------------------------------------------------------------------------
64// KeyboardDragMode
65// ---------------------------------------------------------------------------
66
67/// Current mode of a keyboard drag operation.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum KeyboardDragMode {
70    /// No keyboard drag in progress.
71    #[default]
72    Inactive,
73    /// Item picked up, awaiting target selection.
74    Holding,
75    /// Actively navigating between drop targets.
76    Navigating,
77}
78
79impl KeyboardDragMode {
80    /// Returns true if a drag is in progress.
81    #[must_use]
82    pub fn is_active(self) -> bool {
83        !matches!(self, Self::Inactive)
84    }
85
86    /// Returns the stable string representation.
87    #[must_use]
88    pub const fn as_str(self) -> &'static str {
89        match self {
90            Self::Inactive => "inactive",
91            Self::Holding => "holding",
92            Self::Navigating => "navigating",
93        }
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Direction
99// ---------------------------------------------------------------------------
100
101/// Navigation direction for keyboard drag target selection.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Direction {
104    Up,
105    Down,
106    Left,
107    Right,
108}
109
110impl Direction {
111    /// Returns the opposite direction.
112    #[must_use]
113    pub const fn opposite(self) -> Self {
114        match self {
115            Self::Up => Self::Down,
116            Self::Down => Self::Up,
117            Self::Left => Self::Right,
118            Self::Right => Self::Left,
119        }
120    }
121
122    /// Returns true for vertical directions.
123    #[must_use]
124    pub const fn is_vertical(self) -> bool {
125        matches!(self, Self::Up | Self::Down)
126    }
127}
128
129// ---------------------------------------------------------------------------
130// DropTargetInfo
131// ---------------------------------------------------------------------------
132
133/// Information about a potential drop target for keyboard navigation.
134#[derive(Debug, Clone)]
135pub struct DropTargetInfo {
136    /// Unique identifier for the target widget.
137    pub id: WidgetId,
138    /// Human-readable name for accessibility.
139    pub name: String,
140    /// Bounding rectangle in screen coordinates.
141    pub bounds: Rect,
142    /// Accepted drag types (MIME-like patterns).
143    pub accepted_types: Vec<String>,
144    /// Whether this target is currently enabled.
145    pub enabled: bool,
146}
147
148impl DropTargetInfo {
149    /// Create a new drop target info.
150    #[must_use]
151    pub fn new(id: WidgetId, name: impl Into<String>, bounds: Rect) -> Self {
152        Self {
153            id,
154            name: name.into(),
155            bounds,
156            accepted_types: Vec::new(),
157            enabled: true,
158        }
159    }
160
161    /// Add accepted drag types.
162    #[must_use]
163    pub fn with_accepted_types(mut self, types: Vec<String>) -> Self {
164        self.accepted_types = types;
165        self
166    }
167
168    /// Set enabled state.
169    #[must_use]
170    pub fn with_enabled(mut self, enabled: bool) -> Self {
171        self.enabled = enabled;
172        self
173    }
174
175    /// Check if this target can accept the given payload type.
176    #[must_use]
177    pub fn can_accept(&self, drag_type: &str) -> bool {
178        if !self.enabled {
179            return false;
180        }
181        if self.accepted_types.is_empty() {
182            return true; // Accept any if no filter specified
183        }
184        self.accepted_types.iter().any(|pattern| {
185            if pattern == "*" || pattern == "*/*" {
186                true
187            } else if let Some(prefix) = pattern.strip_suffix("/*") {
188                drag_type.starts_with(prefix)
189                    && drag_type.as_bytes().get(prefix.len()) == Some(&b'/')
190            } else {
191                pattern == drag_type
192            }
193        })
194    }
195
196    /// Returns the center point of this target's bounds.
197    #[must_use]
198    pub fn center(&self) -> (u16, u16) {
199        (
200            self.bounds.x + self.bounds.width / 2,
201            self.bounds.y + self.bounds.height / 2,
202        )
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Announcement
208// ---------------------------------------------------------------------------
209
210/// Screen reader announcement for accessibility.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Announcement {
213    /// The text to announce.
214    pub text: String,
215    /// Priority level (higher = more important).
216    pub priority: AnnouncementPriority,
217}
218
219/// Priority level for announcements.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
221pub enum AnnouncementPriority {
222    /// Low priority, may be skipped if queue is full.
223    Low,
224    /// Normal priority.
225    #[default]
226    Normal,
227    /// High priority, interrupts current announcement.
228    High,
229}
230
231impl Announcement {
232    /// Create a normal priority announcement.
233    #[must_use]
234    pub fn normal(text: impl Into<String>) -> Self {
235        Self {
236            text: text.into(),
237            priority: AnnouncementPriority::Normal,
238        }
239    }
240
241    /// Create a high priority announcement.
242    #[must_use]
243    pub fn high(text: impl Into<String>) -> Self {
244        Self {
245            text: text.into(),
246            priority: AnnouncementPriority::High,
247        }
248    }
249}
250
251// ---------------------------------------------------------------------------
252// KeyboardDragConfig
253// ---------------------------------------------------------------------------
254
255/// Configuration for keyboard drag behavior.
256#[derive(Debug, Clone)]
257pub struct KeyboardDragConfig {
258    /// Keys that activate drag (pick up or drop).
259    /// Default: Space, Enter
260    pub activate_keys: Vec<ActivateKey>,
261
262    /// Whether Escape cancels the drag.
263    pub cancel_on_escape: bool,
264
265    /// Style for highlighting the selected drop target.
266    pub target_highlight_style: TargetHighlightStyle,
267
268    /// Style for highlighting invalid drop targets.
269    pub invalid_target_style: TargetHighlightStyle,
270
271    /// Whether to wrap around when navigating past the last/first target.
272    pub wrap_navigation: bool,
273
274    /// Maximum announcements to queue.
275    pub max_announcement_queue: usize,
276}
277
278/// Keys that can activate drag operations.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum ActivateKey {
281    Space,
282    Enter,
283}
284
285impl Default for KeyboardDragConfig {
286    fn default() -> Self {
287        Self {
288            activate_keys: vec![ActivateKey::Space, ActivateKey::Enter],
289            cancel_on_escape: true,
290            target_highlight_style: TargetHighlightStyle::default(),
291            invalid_target_style: TargetHighlightStyle::invalid_default(),
292            wrap_navigation: true,
293            max_announcement_queue: 5,
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// TargetHighlightStyle
300// ---------------------------------------------------------------------------
301
302/// Visual style for highlighting drop targets during keyboard drag.
303#[derive(Debug, Clone)]
304pub struct TargetHighlightStyle {
305    /// Border style (character to use for highlighting).
306    pub border_char: char,
307    /// Foreground color for the highlight border.
308    pub border_fg: PackedRgba,
309    /// Background color for the target area.
310    pub background: Option<PackedRgba>,
311    /// Whether to render a pulsing animation.
312    pub animate_pulse: bool,
313}
314
315impl Default for TargetHighlightStyle {
316    fn default() -> Self {
317        Self {
318            border_char: '█',
319            border_fg: PackedRgba::rgb(100, 180, 255), // Blue highlight
320            background: Some(PackedRgba::rgba(100, 180, 255, 40)), // Subtle blue tint
321            animate_pulse: true,
322        }
323    }
324}
325
326impl TargetHighlightStyle {
327    /// Style for invalid drop targets.
328    #[must_use]
329    pub fn invalid_default() -> Self {
330        Self {
331            border_char: '▪',
332            border_fg: PackedRgba::rgb(180, 100, 100), // Red highlight
333            background: Some(PackedRgba::rgba(180, 100, 100, 20)), // Subtle red tint
334            animate_pulse: false,
335        }
336    }
337
338    /// Create a custom style.
339    #[must_use]
340    pub fn new(border_char: char, fg: PackedRgba) -> Self {
341        Self {
342            border_char,
343            border_fg: fg,
344            background: None,
345            animate_pulse: false,
346        }
347    }
348
349    /// Set background color.
350    #[must_use]
351    pub fn with_background(mut self, bg: PackedRgba) -> Self {
352        self.background = Some(bg);
353        self
354    }
355
356    /// Enable pulse animation.
357    #[must_use]
358    pub fn with_pulse(mut self) -> Self {
359        self.animate_pulse = true;
360        self
361    }
362}
363
364// ---------------------------------------------------------------------------
365// KeyboardDragState
366// ---------------------------------------------------------------------------
367
368/// State of an active keyboard drag operation.
369#[derive(Debug, Clone)]
370pub struct KeyboardDragState {
371    /// Widget that initiated the drag.
372    pub source_id: WidgetId,
373    /// Data being dragged.
374    pub payload: DragPayload,
375    /// Currently selected drop target index (into available targets list).
376    pub selected_target_index: Option<usize>,
377    /// Current mode.
378    pub mode: KeyboardDragMode,
379    /// Animation tick for pulse effect.
380    pub animation_tick: u8,
381}
382
383impl KeyboardDragState {
384    /// Create a new keyboard drag state.
385    fn new(source_id: WidgetId, payload: DragPayload) -> Self {
386        Self {
387            source_id,
388            payload,
389            selected_target_index: None,
390            mode: KeyboardDragMode::Holding,
391            animation_tick: 0,
392        }
393    }
394
395    /// Advance the animation tick.
396    pub fn tick_animation(&mut self) {
397        self.animation_tick = self.animation_tick.wrapping_add(1);
398    }
399
400    /// Get the pulse intensity (0.0 to 1.0) for animation.
401    #[must_use]
402    pub fn pulse_intensity(&self) -> f32 {
403        // Simple sine-based pulse: 0.5 + 0.5 * sin(tick * 0.15)
404        let angle = self.animation_tick as f32 * 0.15;
405        0.5 + 0.5 * angle.sin()
406    }
407}
408
409// ---------------------------------------------------------------------------
410// KeyboardDragManager
411// ---------------------------------------------------------------------------
412
413/// Manager for keyboard-driven drag operations.
414#[derive(Debug)]
415pub struct KeyboardDragManager {
416    /// Configuration.
417    config: KeyboardDragConfig,
418    /// Current drag state (if any).
419    state: Option<KeyboardDragState>,
420    /// Announcement queue.
421    announcements: Vec<Announcement>,
422}
423
424impl KeyboardDragManager {
425    /// Create a new keyboard drag manager.
426    #[must_use]
427    pub fn new(config: KeyboardDragConfig) -> Self {
428        Self {
429            config,
430            state: None,
431            announcements: Vec::new(),
432        }
433    }
434
435    /// Create with default configuration.
436    #[must_use]
437    pub fn with_defaults() -> Self {
438        Self::new(KeyboardDragConfig::default())
439    }
440
441    /// Get the current drag mode.
442    #[must_use]
443    pub fn mode(&self) -> KeyboardDragMode {
444        self.state
445            .as_ref()
446            .map(|s| s.mode)
447            .unwrap_or(KeyboardDragMode::Inactive)
448    }
449
450    /// Check if a drag is active.
451    #[must_use]
452    pub fn is_active(&self) -> bool {
453        self.state.is_some()
454    }
455
456    /// Get the current drag state.
457    #[must_use]
458    pub fn state(&self) -> Option<&KeyboardDragState> {
459        self.state.as_ref()
460    }
461
462    /// Get mutable access to the drag state.
463    pub fn state_mut(&mut self) -> Option<&mut KeyboardDragState> {
464        self.state.as_mut()
465    }
466
467    /// Start a keyboard drag operation.
468    ///
469    /// Returns `true` if the drag was started successfully.
470    /// Returns `false` if a drag is already in progress.
471    pub fn start_drag(&mut self, source_id: WidgetId, payload: DragPayload) -> bool {
472        if self.state.is_some() {
473            return false;
474        }
475
476        let description = payload
477            .display_text
478            .as_deref()
479            .or_else(|| payload.as_text())
480            .unwrap_or("item");
481
482        self.queue_announcement(Announcement::high(format!("Picked up: {description}")));
483
484        self.state = Some(KeyboardDragState::new(source_id, payload));
485        true
486    }
487
488    /// Navigate to the next drop target in the given direction.
489    ///
490    /// Returns the newly selected target info if navigation succeeded.
491    pub fn navigate_targets<'a>(
492        &mut self,
493        direction: Direction,
494        targets: &'a [DropTargetInfo],
495    ) -> Option<&'a DropTargetInfo> {
496        let state = self.state.as_mut()?;
497
498        if targets.is_empty() {
499            return None;
500        }
501
502        // Filter to valid targets that can accept the payload
503        let valid_indices: Vec<usize> = targets
504            .iter()
505            .enumerate()
506            .filter(|(_, t)| t.can_accept(&state.payload.drag_type))
507            .map(|(i, _)| i)
508            .collect();
509
510        if valid_indices.is_empty() {
511            self.queue_announcement(Announcement::normal("No valid drop targets available"));
512            return None;
513        }
514
515        // Update mode to navigating
516        state.mode = KeyboardDragMode::Navigating;
517
518        // Find current position among valid targets
519        let current_valid_idx = state
520            .selected_target_index
521            .and_then(|idx| valid_indices.iter().position(|&i| i == idx));
522
523        // Calculate next index based on direction and current selection
524        let next_valid_idx = match (current_valid_idx, direction) {
525            (None, _) => 0, // No selection, start at first
526            (Some(idx), Direction::Down | Direction::Right) => {
527                if idx + 1 < valid_indices.len() {
528                    idx + 1
529                } else if self.config.wrap_navigation {
530                    0
531                } else {
532                    idx
533                }
534            }
535            (Some(idx), Direction::Up | Direction::Left) => {
536                if idx > 0 {
537                    idx - 1
538                } else if self.config.wrap_navigation {
539                    valid_indices.len() - 1
540                } else {
541                    idx
542                }
543            }
544        };
545
546        let target_idx = valid_indices[next_valid_idx];
547        state.selected_target_index = Some(target_idx);
548
549        let target = &targets[target_idx];
550        let position = format!("{} of {}", next_valid_idx + 1, valid_indices.len());
551        self.queue_announcement(Announcement::normal(format!(
552            "Drop target: {} ({})",
553            target.name, position
554        )));
555
556        Some(target)
557    }
558
559    /// Navigate to a specific target by index.
560    pub fn select_target(&mut self, target_index: usize, targets: &[DropTargetInfo]) -> bool {
561        let Some(state) = self.state.as_mut() else {
562            return false;
563        };
564
565        if target_index >= targets.len() {
566            return false;
567        }
568
569        let target = &targets[target_index];
570        if !target.can_accept(&state.payload.drag_type) {
571            return false;
572        }
573
574        state.mode = KeyboardDragMode::Navigating;
575        state.selected_target_index = Some(target_index);
576
577        self.queue_announcement(Announcement::normal(format!(
578            "Drop target: {}",
579            target.name
580        )));
581        true
582    }
583
584    /// Complete the drag operation (drop on selected target).
585    ///
586    /// Returns `None` if no target is selected or no drag is active.
587    /// Returns `Some((payload, target_index))` with the payload and target index.
588    pub fn complete_drag(&mut self) -> Option<(DragPayload, usize)> {
589        let state = self.state.take()?;
590        let target_idx = state.selected_target_index?;
591
592        Some((state.payload, target_idx))
593    }
594
595    /// Complete the drag with a specific target and get the drop result info.
596    pub fn drop_on_target(&mut self, targets: &[DropTargetInfo]) -> Option<KeyboardDropResult> {
597        let state = self.state.take()?;
598        let target_idx = state.selected_target_index?;
599        let target = targets.get(target_idx)?;
600
601        self.queue_announcement(Announcement::high(format!("Dropped on: {}", target.name)));
602
603        Some(KeyboardDropResult {
604            payload: state.payload,
605            source_id: state.source_id,
606            target_id: target.id,
607            target_index: target_idx,
608        })
609    }
610
611    /// Cancel the current drag operation.
612    ///
613    /// Returns the payload if a drag was active.
614    pub fn cancel_drag(&mut self) -> Option<DragPayload> {
615        let state = self.state.take()?;
616        self.queue_announcement(Announcement::normal("Drop cancelled"));
617        Some(state.payload)
618    }
619
620    /// Handle key press during keyboard drag.
621    ///
622    /// Returns `KeyboardDragAction` indicating what action was triggered.
623    pub fn handle_key(&mut self, key: KeyboardDragKey) -> KeyboardDragAction {
624        match key {
625            KeyboardDragKey::Activate => {
626                if self.is_active() {
627                    // If navigating with a selected target, complete the drop
628                    if let Some(state) = &self.state
629                        && state.selected_target_index.is_some()
630                    {
631                        KeyboardDragAction::Drop
632                    } else {
633                        // No target selected yet, stay in current state
634                        KeyboardDragAction::None
635                    }
636                } else {
637                    // No drag active, signal to pick up
638                    KeyboardDragAction::PickUp
639                }
640            }
641            KeyboardDragKey::Cancel => {
642                if self.is_active() && self.config.cancel_on_escape {
643                    KeyboardDragAction::Cancel
644                } else {
645                    KeyboardDragAction::None
646                }
647            }
648            KeyboardDragKey::Navigate(dir) => {
649                if self.is_active() {
650                    KeyboardDragAction::Navigate(dir)
651                } else {
652                    KeyboardDragAction::None
653                }
654            }
655        }
656    }
657
658    /// Advance animation state.
659    pub fn tick(&mut self) {
660        if let Some(state) = &mut self.state {
661            state.tick_animation();
662        }
663    }
664
665    /// Get and clear pending announcements.
666    pub fn drain_announcements(&mut self) -> Vec<Announcement> {
667        std::mem::take(&mut self.announcements)
668    }
669
670    /// Peek at pending announcements without clearing.
671    #[must_use]
672    pub fn announcements(&self) -> &[Announcement] {
673        &self.announcements
674    }
675
676    /// Queue an announcement for screen readers.
677    fn queue_announcement(&mut self, announcement: Announcement) {
678        if self.announcements.len() >= self.config.max_announcement_queue {
679            // Remove lowest priority announcement
680            if let Some(pos) = self
681                .announcements
682                .iter()
683                .enumerate()
684                .min_by_key(|(_, a)| a.priority)
685                .map(|(i, _)| i)
686            {
687                self.announcements.remove(pos);
688            }
689        }
690        self.announcements.push(announcement);
691    }
692
693    /// Render the target highlight overlay.
694    pub fn render_highlight(&self, targets: &[DropTargetInfo], frame: &mut Frame) {
695        let Some(state) = &self.state else {
696            return;
697        };
698        let Some(target_idx) = state.selected_target_index else {
699            return;
700        };
701        let Some(target) = targets.get(target_idx) else {
702            return;
703        };
704
705        let style = if target.can_accept(&state.payload.drag_type) {
706            &self.config.target_highlight_style
707        } else {
708            &self.config.invalid_target_style
709        };
710
711        let bounds = target.bounds;
712        if bounds.is_empty() {
713            return;
714        }
715
716        // Apply background tint if configured
717        if let Some(bg) = style.background {
718            // Calculate effective alpha based on pulse
719            let alpha = if style.animate_pulse {
720                let base_alpha = (bg.0 & 0xFF) as f32 / 255.0;
721                let pulsed = base_alpha * (0.5 + 0.5 * state.pulse_intensity());
722                (pulsed * 255.0) as u8
723            } else {
724                (bg.0 & 0xFF) as u8
725            };
726
727            let effective_bg = PackedRgba((bg.0 & 0xFFFF_FF00) | alpha as u32);
728
729            // Fill background
730            for y in bounds.y..bounds.y.saturating_add(bounds.height) {
731                for x in bounds.x..bounds.x.saturating_add(bounds.width) {
732                    if let Some(cell) = frame.buffer.get_mut(x, y) {
733                        cell.bg = effective_bg;
734                    }
735                }
736            }
737        }
738
739        // Draw highlight border
740        let fg_style = Style::new().fg(style.border_fg);
741        let border_char = style.border_char;
742
743        // Top and bottom borders
744        for x in bounds.x..bounds.x.saturating_add(bounds.width) {
745            // Top
746            if let Some(cell) = frame.buffer.get_mut(x, bounds.y) {
747                cell.content = CellContent::from_char(border_char);
748                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
749            }
750            // Bottom
751            let bottom_y = bounds.y.saturating_add(bounds.height.saturating_sub(1));
752            if bounds.height > 1
753                && let Some(cell) = frame.buffer.get_mut(x, bottom_y)
754            {
755                cell.content = CellContent::from_char(border_char);
756                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
757            }
758        }
759
760        // Left and right borders (excluding corners)
761        for y in
762            bounds.y.saturating_add(1)..bounds.y.saturating_add(bounds.height.saturating_sub(1))
763        {
764            // Left
765            if let Some(cell) = frame.buffer.get_mut(bounds.x, y) {
766                cell.content = CellContent::from_char(border_char);
767                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
768            }
769            // Right
770            let right_x = bounds.x.saturating_add(bounds.width.saturating_sub(1));
771            if bounds.width > 1
772                && let Some(cell) = frame.buffer.get_mut(right_x, y)
773            {
774                cell.content = CellContent::from_char(border_char);
775                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
776            }
777        }
778    }
779}
780
781// ---------------------------------------------------------------------------
782// KeyboardDragKey
783// ---------------------------------------------------------------------------
784
785/// Key events relevant to keyboard drag operations.
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub enum KeyboardDragKey {
788    /// Activation key (Space or Enter by default).
789    Activate,
790    /// Cancellation key (Escape by default).
791    Cancel,
792    /// Navigation key.
793    Navigate(Direction),
794}
795
796// ---------------------------------------------------------------------------
797// KeyboardDragAction
798// ---------------------------------------------------------------------------
799
800/// Action resulting from key handling.
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum KeyboardDragAction {
803    /// No action needed.
804    None,
805    /// Pick up the focused item to start a drag.
806    PickUp,
807    /// Navigate to next target in direction.
808    Navigate(Direction),
809    /// Drop on the selected target.
810    Drop,
811    /// Cancel the drag operation.
812    Cancel,
813}
814
815// ---------------------------------------------------------------------------
816// KeyboardDropResult
817// ---------------------------------------------------------------------------
818
819/// Result of a completed keyboard drag-and-drop operation.
820#[derive(Debug, Clone)]
821pub struct KeyboardDropResult {
822    /// The dropped payload.
823    pub payload: DragPayload,
824    /// Source widget ID.
825    pub source_id: WidgetId,
826    /// Target widget ID.
827    pub target_id: WidgetId,
828    /// Target index in the targets list.
829    pub target_index: usize,
830}
831
832// ---------------------------------------------------------------------------
833// Tests
834// ---------------------------------------------------------------------------
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    // === KeyboardDragMode tests ===
841
842    #[test]
843    fn mode_is_active() {
844        assert!(!KeyboardDragMode::Inactive.is_active());
845        assert!(KeyboardDragMode::Holding.is_active());
846        assert!(KeyboardDragMode::Navigating.is_active());
847    }
848
849    #[test]
850    fn mode_as_str() {
851        assert_eq!(KeyboardDragMode::Inactive.as_str(), "inactive");
852        assert_eq!(KeyboardDragMode::Holding.as_str(), "holding");
853        assert_eq!(KeyboardDragMode::Navigating.as_str(), "navigating");
854    }
855
856    // === Direction tests ===
857
858    #[test]
859    fn direction_opposite() {
860        assert_eq!(Direction::Up.opposite(), Direction::Down);
861        assert_eq!(Direction::Down.opposite(), Direction::Up);
862        assert_eq!(Direction::Left.opposite(), Direction::Right);
863        assert_eq!(Direction::Right.opposite(), Direction::Left);
864    }
865
866    #[test]
867    fn direction_is_vertical() {
868        assert!(Direction::Up.is_vertical());
869        assert!(Direction::Down.is_vertical());
870        assert!(!Direction::Left.is_vertical());
871        assert!(!Direction::Right.is_vertical());
872    }
873
874    // === DropTargetInfo tests ===
875
876    #[test]
877    fn drop_target_info_new() {
878        let target = DropTargetInfo::new(WidgetId(1), "Test Target", Rect::new(0, 0, 10, 5));
879        assert_eq!(target.id, WidgetId(1));
880        assert_eq!(target.name, "Test Target");
881        assert!(target.enabled);
882        assert!(target.accepted_types.is_empty());
883    }
884
885    #[test]
886    fn drop_target_info_can_accept_any() {
887        let target = DropTargetInfo::new(WidgetId(1), "Any", Rect::new(0, 0, 1, 1));
888        // No filter means accept any
889        assert!(target.can_accept("text/plain"));
890        assert!(target.can_accept("application/json"));
891    }
892
893    #[test]
894    fn drop_target_info_can_accept_filtered() {
895        let target = DropTargetInfo::new(WidgetId(1), "Text Only", Rect::new(0, 0, 1, 1))
896            .with_accepted_types(vec!["text/plain".to_string()]);
897        assert!(target.can_accept("text/plain"));
898        assert!(!target.can_accept("application/json"));
899    }
900
901    #[test]
902    fn drop_target_info_can_accept_wildcard() {
903        let target = DropTargetInfo::new(WidgetId(1), "All Text", Rect::new(0, 0, 1, 1))
904            .with_accepted_types(vec!["text/*".to_string()]);
905        assert!(target.can_accept("text/plain"));
906        assert!(target.can_accept("text/html"));
907        assert!(!target.can_accept("application/json"));
908    }
909
910    #[test]
911    fn drop_target_info_disabled() {
912        let target =
913            DropTargetInfo::new(WidgetId(1), "Disabled", Rect::new(0, 0, 1, 1)).with_enabled(false);
914        assert!(!target.can_accept("text/plain"));
915    }
916
917    #[test]
918    fn drop_target_info_center() {
919        let target = DropTargetInfo::new(WidgetId(1), "Test", Rect::new(10, 20, 10, 6));
920        assert_eq!(target.center(), (15, 23));
921    }
922
923    // === Announcement tests ===
924
925    #[test]
926    fn announcement_normal() {
927        let a = Announcement::normal("Test message");
928        assert_eq!(a.text, "Test message");
929        assert_eq!(a.priority, AnnouncementPriority::Normal);
930    }
931
932    #[test]
933    fn announcement_high() {
934        let a = Announcement::high("Important!");
935        assert_eq!(a.priority, AnnouncementPriority::High);
936    }
937
938    // === KeyboardDragConfig tests ===
939
940    #[test]
941    fn config_defaults() {
942        let config = KeyboardDragConfig::default();
943        assert!(config.cancel_on_escape);
944        assert!(config.wrap_navigation);
945        assert_eq!(config.activate_keys.len(), 2);
946    }
947
948    // === KeyboardDragState tests ===
949
950    #[test]
951    fn drag_state_animation() {
952        let payload = DragPayload::text("test");
953        let mut state = KeyboardDragState::new(WidgetId(1), payload);
954
955        let initial_tick = state.animation_tick;
956        state.tick_animation();
957        assert_eq!(state.animation_tick, initial_tick.wrapping_add(1));
958    }
959
960    #[test]
961    fn drag_state_pulse_intensity() {
962        let payload = DragPayload::text("test");
963        let state = KeyboardDragState::new(WidgetId(1), payload);
964
965        let intensity = state.pulse_intensity();
966        assert!((0.0..=1.0).contains(&intensity));
967    }
968
969    // === KeyboardDragManager tests ===
970
971    #[test]
972    fn manager_start_drag() {
973        let mut manager = KeyboardDragManager::with_defaults();
974        assert!(!manager.is_active());
975
976        let payload = DragPayload::text("item");
977        assert!(manager.start_drag(WidgetId(1), payload));
978        assert!(manager.is_active());
979        assert_eq!(manager.mode(), KeyboardDragMode::Holding);
980    }
981
982    #[test]
983    fn manager_double_start_fails() {
984        let mut manager = KeyboardDragManager::with_defaults();
985
986        assert!(manager.start_drag(WidgetId(1), DragPayload::text("first")));
987        assert!(!manager.start_drag(WidgetId(2), DragPayload::text("second")));
988    }
989
990    #[test]
991    fn manager_cancel_drag() {
992        let mut manager = KeyboardDragManager::with_defaults();
993        manager.start_drag(WidgetId(1), DragPayload::text("item"));
994
995        let payload = manager.cancel_drag();
996        assert!(payload.is_some());
997        assert!(!manager.is_active());
998    }
999
1000    #[test]
1001    fn manager_cancel_inactive() {
1002        let mut manager = KeyboardDragManager::with_defaults();
1003        assert!(manager.cancel_drag().is_none());
1004    }
1005
1006    #[test]
1007    fn manager_navigate_targets() {
1008        let mut manager = KeyboardDragManager::with_defaults();
1009        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1010
1011        let targets = vec![
1012            DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1013            DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
1014        ];
1015
1016        let selected = manager.navigate_targets(Direction::Down, &targets);
1017        assert!(selected.is_some());
1018        assert_eq!(selected.unwrap().name, "Target A");
1019        assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1020    }
1021
1022    #[test]
1023    fn manager_navigate_empty_targets() {
1024        let mut manager = KeyboardDragManager::with_defaults();
1025        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1026
1027        let targets: Vec<DropTargetInfo> = vec![];
1028        let selected = manager.navigate_targets(Direction::Down, &targets);
1029        assert!(selected.is_none());
1030    }
1031
1032    #[test]
1033    fn manager_navigate_wrap() {
1034        let mut manager = KeyboardDragManager::with_defaults();
1035        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1036
1037        let targets = vec![
1038            DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1039            DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
1040        ];
1041
1042        // Navigate to first
1043        manager.navigate_targets(Direction::Down, &targets);
1044        // Navigate to second
1045        manager.navigate_targets(Direction::Down, &targets);
1046        // Navigate past end, should wrap to first
1047        let selected = manager.navigate_targets(Direction::Down, &targets);
1048
1049        assert_eq!(selected.unwrap().name, "Target A");
1050    }
1051
1052    #[test]
1053    fn manager_complete_drag() {
1054        let mut manager = KeyboardDragManager::with_defaults();
1055        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1056
1057        let targets = vec![DropTargetInfo::new(
1058            WidgetId(10),
1059            "Target A",
1060            Rect::new(0, 0, 10, 5),
1061        )];
1062
1063        manager.navigate_targets(Direction::Down, &targets);
1064
1065        let result = manager.complete_drag();
1066        assert!(result.is_some());
1067        let (payload, idx) = result.unwrap();
1068        assert_eq!(payload.as_text(), Some("item"));
1069        assert_eq!(idx, 0);
1070        assert!(!manager.is_active());
1071    }
1072
1073    #[test]
1074    fn manager_complete_without_target() {
1075        let mut manager = KeyboardDragManager::with_defaults();
1076        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1077
1078        // No target selected
1079        let result = manager.complete_drag();
1080        assert!(result.is_none());
1081    }
1082
1083    #[test]
1084    fn manager_handle_key_pickup() {
1085        let mut manager = KeyboardDragManager::with_defaults();
1086        let action = manager.handle_key(KeyboardDragKey::Activate);
1087        assert_eq!(action, KeyboardDragAction::PickUp);
1088    }
1089
1090    #[test]
1091    fn manager_handle_key_drop() {
1092        let mut manager = KeyboardDragManager::with_defaults();
1093        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1094
1095        // Select a target
1096        manager.state_mut().unwrap().selected_target_index = Some(0);
1097
1098        let action = manager.handle_key(KeyboardDragKey::Activate);
1099        assert_eq!(action, KeyboardDragAction::Drop);
1100    }
1101
1102    #[test]
1103    fn manager_handle_key_cancel() {
1104        let mut manager = KeyboardDragManager::with_defaults();
1105        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1106
1107        let action = manager.handle_key(KeyboardDragKey::Cancel);
1108        assert_eq!(action, KeyboardDragAction::Cancel);
1109    }
1110
1111    #[test]
1112    fn manager_handle_key_navigate() {
1113        let mut manager = KeyboardDragManager::with_defaults();
1114        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1115
1116        let action = manager.handle_key(KeyboardDragKey::Navigate(Direction::Down));
1117        assert_eq!(action, KeyboardDragAction::Navigate(Direction::Down));
1118    }
1119
1120    #[test]
1121    fn manager_announcements() {
1122        let mut manager = KeyboardDragManager::with_defaults();
1123        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1124
1125        let announcements = manager.drain_announcements();
1126        assert!(!announcements.is_empty());
1127        assert!(announcements[0].text.contains("Picked up"));
1128    }
1129
1130    #[test]
1131    fn manager_announcement_queue_limit() {
1132        let config = KeyboardDragConfig {
1133            max_announcement_queue: 2,
1134            ..Default::default()
1135        };
1136        let mut manager = KeyboardDragManager::new(config);
1137
1138        // Fill queue
1139        manager.start_drag(WidgetId(1), DragPayload::text("item1"));
1140        manager.cancel_drag();
1141        manager.start_drag(WidgetId(2), DragPayload::text("item2"));
1142
1143        // Should have at most 2 announcements
1144        assert!(manager.announcements().len() <= 2);
1145    }
1146
1147    // === Target filtering tests ===
1148
1149    #[test]
1150    fn manager_navigate_skips_incompatible() {
1151        let mut manager = KeyboardDragManager::with_defaults();
1152        manager.start_drag(WidgetId(1), DragPayload::new("text/plain", vec![]));
1153
1154        let targets = vec![
1155            DropTargetInfo::new(WidgetId(10), "Text Target", Rect::new(0, 0, 10, 5))
1156                .with_accepted_types(vec!["text/plain".to_string()]),
1157            DropTargetInfo::new(WidgetId(11), "Image Target", Rect::new(20, 0, 10, 5))
1158                .with_accepted_types(vec!["image/*".to_string()]),
1159            DropTargetInfo::new(WidgetId(12), "Text Target 2", Rect::new(40, 0, 10, 5))
1160                .with_accepted_types(vec!["text/plain".to_string()]),
1161        ];
1162
1163        // First navigation should select Text Target
1164        let selected = manager.navigate_targets(Direction::Down, &targets);
1165        assert_eq!(selected.unwrap().name, "Text Target");
1166
1167        // Second navigation should skip Image Target and select Text Target 2
1168        let selected = manager.navigate_targets(Direction::Down, &targets);
1169        assert_eq!(selected.unwrap().name, "Text Target 2");
1170    }
1171
1172    // === Integration tests ===
1173
1174    #[test]
1175    fn full_keyboard_drag_lifecycle() {
1176        let mut manager = KeyboardDragManager::with_defaults();
1177
1178        // 1. Start drag
1179        assert!(manager.start_drag(WidgetId(1), DragPayload::text("dragged_item")));
1180        assert_eq!(manager.mode(), KeyboardDragMode::Holding);
1181
1182        let targets = vec![
1183            DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1184            DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(0, 10, 10, 5)),
1185        ];
1186
1187        // 2. Navigate to target
1188        manager.navigate_targets(Direction::Down, &targets);
1189        assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1190
1191        // 3. Navigate to next target
1192        manager.navigate_targets(Direction::Down, &targets);
1193
1194        // 4. Complete drop
1195        let result = manager.drop_on_target(&targets);
1196        assert!(result.is_some());
1197        let result = result.unwrap();
1198        assert_eq!(result.payload.as_text(), Some("dragged_item"));
1199        assert_eq!(result.target_id, WidgetId(11));
1200        assert_eq!(result.target_index, 1);
1201
1202        // 5. Manager is now inactive
1203        assert!(!manager.is_active());
1204    }
1205}