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 = "use the returned state (if any)"]
458    pub fn state(&self) -> Option<&KeyboardDragState> {
459        self.state.as_ref()
460    }
461
462    /// Get mutable access to the drag state.
463    #[must_use = "use the returned state (if any)"]
464    pub fn state_mut(&mut self) -> Option<&mut KeyboardDragState> {
465        self.state.as_mut()
466    }
467
468    /// Start a keyboard drag operation.
469    ///
470    /// Returns `true` if the drag was started successfully.
471    /// Returns `false` if a drag is already in progress.
472    pub fn start_drag(&mut self, source_id: WidgetId, payload: DragPayload) -> bool {
473        if self.state.is_some() {
474            return false;
475        }
476
477        let description = payload
478            .display_text
479            .as_deref()
480            .or_else(|| payload.as_text())
481            .unwrap_or("item");
482
483        self.queue_announcement(Announcement::high(format!("Picked up: {description}")));
484
485        self.state = Some(KeyboardDragState::new(source_id, payload));
486        true
487    }
488
489    /// Navigate to the next drop target in the given direction.
490    ///
491    /// Returns the newly selected target info if navigation succeeded.
492    #[must_use = "use the returned target (if any)"]
493    pub fn navigate_targets<'a>(
494        &mut self,
495        direction: Direction,
496        targets: &'a [DropTargetInfo],
497    ) -> Option<&'a DropTargetInfo> {
498        let state = self.state.as_mut()?;
499
500        if targets.is_empty() {
501            return None;
502        }
503
504        // Filter to valid targets that can accept the payload
505        let valid_indices: Vec<usize> = targets
506            .iter()
507            .enumerate()
508            .filter(|(_, t)| t.can_accept(&state.payload.drag_type))
509            .map(|(i, _)| i)
510            .collect();
511
512        if valid_indices.is_empty() {
513            self.queue_announcement(Announcement::normal("No valid drop targets available"));
514            return None;
515        }
516
517        // Update mode to navigating
518        state.mode = KeyboardDragMode::Navigating;
519
520        // Find current position among valid targets
521        let current_valid_idx = state
522            .selected_target_index
523            .and_then(|idx| valid_indices.iter().position(|&i| i == idx));
524
525        // Calculate next index based on direction and current selection
526        let next_valid_idx = match (current_valid_idx, direction) {
527            (None, _) => 0, // No selection, start at first
528            (Some(idx), Direction::Down | Direction::Right) => {
529                if idx + 1 < valid_indices.len() {
530                    idx + 1
531                } else if self.config.wrap_navigation {
532                    0
533                } else {
534                    idx
535                }
536            }
537            (Some(idx), Direction::Up | Direction::Left) => {
538                if idx > 0 {
539                    idx - 1
540                } else if self.config.wrap_navigation {
541                    valid_indices.len() - 1
542                } else {
543                    idx
544                }
545            }
546        };
547
548        let target_idx = valid_indices[next_valid_idx];
549        state.selected_target_index = Some(target_idx);
550
551        let target = &targets[target_idx];
552        let position = format!("{} of {}", next_valid_idx + 1, valid_indices.len());
553        self.queue_announcement(Announcement::normal(format!(
554            "Drop target: {} ({})",
555            target.name, position
556        )));
557
558        Some(target)
559    }
560
561    /// Navigate to a specific target by index.
562    pub fn select_target(&mut self, target_index: usize, targets: &[DropTargetInfo]) -> bool {
563        let Some(state) = self.state.as_mut() else {
564            return false;
565        };
566
567        if target_index >= targets.len() {
568            return false;
569        }
570
571        let target = &targets[target_index];
572        if !target.can_accept(&state.payload.drag_type) {
573            return false;
574        }
575
576        state.mode = KeyboardDragMode::Navigating;
577        state.selected_target_index = Some(target_index);
578
579        self.queue_announcement(Announcement::normal(format!(
580            "Drop target: {}",
581            target.name
582        )));
583        true
584    }
585
586    /// Complete the drag operation (drop on selected target).
587    ///
588    /// Returns `None` if no target is selected or no drag is active.
589    /// Returns `Some((payload, target_index))` with the payload and target index.
590    #[must_use = "use the returned (payload, target_index) to complete the drop"]
591    pub fn complete_drag(&mut self) -> Option<(DragPayload, usize)> {
592        let state = self.state.take()?;
593        let target_idx = state.selected_target_index?;
594
595        Some((state.payload, target_idx))
596    }
597
598    /// Complete the drag with a specific target and get the drop result info.
599    #[must_use = "use the drop result (if any) to apply the drop"]
600    pub fn drop_on_target(&mut self, targets: &[DropTargetInfo]) -> Option<KeyboardDropResult> {
601        let state = self.state.take()?;
602        let target_idx = state.selected_target_index?;
603        let target = targets.get(target_idx)?;
604
605        self.queue_announcement(Announcement::high(format!("Dropped on: {}", target.name)));
606
607        Some(KeyboardDropResult {
608            payload: state.payload,
609            source_id: state.source_id,
610            target_id: target.id,
611            target_index: target_idx,
612        })
613    }
614
615    /// Cancel the current drag operation.
616    ///
617    /// Returns the payload if a drag was active.
618    #[must_use = "use the returned payload (if any) to restore state"]
619    pub fn cancel_drag(&mut self) -> Option<DragPayload> {
620        let state = self.state.take()?;
621        self.queue_announcement(Announcement::normal("Drop cancelled"));
622        Some(state.payload)
623    }
624
625    /// Handle key press during keyboard drag.
626    ///
627    /// Returns `KeyboardDragAction` indicating what action was triggered.
628    pub fn handle_key(&mut self, key: KeyboardDragKey) -> KeyboardDragAction {
629        match key {
630            KeyboardDragKey::Activate => {
631                if self.is_active() {
632                    // If navigating with a selected target, complete the drop
633                    if let Some(state) = &self.state
634                        && state.selected_target_index.is_some()
635                    {
636                        KeyboardDragAction::Drop
637                    } else {
638                        // No target selected yet, stay in current state
639                        KeyboardDragAction::None
640                    }
641                } else {
642                    // No drag active, signal to pick up
643                    KeyboardDragAction::PickUp
644                }
645            }
646            KeyboardDragKey::Cancel => {
647                if self.is_active() && self.config.cancel_on_escape {
648                    KeyboardDragAction::Cancel
649                } else {
650                    KeyboardDragAction::None
651                }
652            }
653            KeyboardDragKey::Navigate(dir) => {
654                if self.is_active() {
655                    KeyboardDragAction::Navigate(dir)
656                } else {
657                    KeyboardDragAction::None
658                }
659            }
660        }
661    }
662
663    /// Advance animation state.
664    pub fn tick(&mut self) {
665        if let Some(state) = &mut self.state {
666            state.tick_animation();
667        }
668    }
669
670    /// Get and clear pending announcements.
671    pub fn drain_announcements(&mut self) -> Vec<Announcement> {
672        std::mem::take(&mut self.announcements)
673    }
674
675    /// Peek at pending announcements without clearing.
676    #[must_use]
677    pub fn announcements(&self) -> &[Announcement] {
678        &self.announcements
679    }
680
681    /// Queue an announcement for screen readers.
682    fn queue_announcement(&mut self, announcement: Announcement) {
683        if self.announcements.len() >= self.config.max_announcement_queue {
684            // Remove lowest priority announcement
685            if let Some(pos) = self
686                .announcements
687                .iter()
688                .enumerate()
689                .min_by_key(|(_, a)| a.priority)
690                .map(|(i, _)| i)
691            {
692                self.announcements.remove(pos);
693            }
694        }
695        self.announcements.push(announcement);
696    }
697
698    /// Render the target highlight overlay.
699    pub fn render_highlight(&self, targets: &[DropTargetInfo], frame: &mut Frame) {
700        let Some(state) = &self.state else {
701            return;
702        };
703        let Some(target_idx) = state.selected_target_index else {
704            return;
705        };
706        let Some(target) = targets.get(target_idx) else {
707            return;
708        };
709
710        let style = if target.can_accept(&state.payload.drag_type) {
711            &self.config.target_highlight_style
712        } else {
713            &self.config.invalid_target_style
714        };
715
716        let bounds = target.bounds;
717        if bounds.is_empty() {
718            return;
719        }
720
721        // Apply background tint if configured
722        if let Some(bg) = style.background {
723            // Calculate effective alpha based on pulse
724            let alpha = if style.animate_pulse {
725                let base_alpha = (bg.0 & 0xFF) as f32 / 255.0;
726                let pulsed = base_alpha * (0.5 + 0.5 * state.pulse_intensity());
727                (pulsed * 255.0) as u8
728            } else {
729                (bg.0 & 0xFF) as u8
730            };
731
732            let effective_bg = PackedRgba((bg.0 & 0xFFFF_FF00) | alpha as u32);
733
734            // Fill background
735            for y in bounds.y..bounds.y.saturating_add(bounds.height) {
736                for x in bounds.x..bounds.x.saturating_add(bounds.width) {
737                    if let Some(cell) = frame.buffer.get_mut(x, y) {
738                        cell.bg = effective_bg;
739                    }
740                }
741            }
742        }
743
744        // Draw highlight border
745        let fg_style = Style::new().fg(style.border_fg);
746        let border_char = style.border_char;
747
748        // Top and bottom borders
749        for x in bounds.x..bounds.x.saturating_add(bounds.width) {
750            // Top
751            if let Some(cell) = frame.buffer.get_mut(x, bounds.y) {
752                cell.content = CellContent::from_char(border_char);
753                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
754            }
755            // Bottom
756            let bottom_y = bounds.y.saturating_add(bounds.height.saturating_sub(1));
757            if bounds.height > 1
758                && let Some(cell) = frame.buffer.get_mut(x, bottom_y)
759            {
760                cell.content = CellContent::from_char(border_char);
761                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
762            }
763        }
764
765        // Left and right borders (excluding corners)
766        for y in
767            bounds.y.saturating_add(1)..bounds.y.saturating_add(bounds.height.saturating_sub(1))
768        {
769            // Left
770            if let Some(cell) = frame.buffer.get_mut(bounds.x, y) {
771                cell.content = CellContent::from_char(border_char);
772                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
773            }
774            // Right
775            let right_x = bounds.x.saturating_add(bounds.width.saturating_sub(1));
776            if bounds.width > 1
777                && let Some(cell) = frame.buffer.get_mut(right_x, y)
778            {
779                cell.content = CellContent::from_char(border_char);
780                cell.fg = fg_style.fg.unwrap_or(style.border_fg);
781            }
782        }
783    }
784}
785
786// ---------------------------------------------------------------------------
787// KeyboardDragKey
788// ---------------------------------------------------------------------------
789
790/// Key events relevant to keyboard drag operations.
791#[derive(Debug, Clone, Copy, PartialEq, Eq)]
792pub enum KeyboardDragKey {
793    /// Activation key (Space or Enter by default).
794    Activate,
795    /// Cancellation key (Escape by default).
796    Cancel,
797    /// Navigation key.
798    Navigate(Direction),
799}
800
801// ---------------------------------------------------------------------------
802// KeyboardDragAction
803// ---------------------------------------------------------------------------
804
805/// Action resulting from key handling.
806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
807pub enum KeyboardDragAction {
808    /// No action needed.
809    None,
810    /// Pick up the focused item to start a drag.
811    PickUp,
812    /// Navigate to next target in direction.
813    Navigate(Direction),
814    /// Drop on the selected target.
815    Drop,
816    /// Cancel the drag operation.
817    Cancel,
818}
819
820// ---------------------------------------------------------------------------
821// KeyboardDropResult
822// ---------------------------------------------------------------------------
823
824/// Result of a completed keyboard drag-and-drop operation.
825#[derive(Debug, Clone)]
826pub struct KeyboardDropResult {
827    /// The dropped payload.
828    pub payload: DragPayload,
829    /// Source widget ID.
830    pub source_id: WidgetId,
831    /// Target widget ID.
832    pub target_id: WidgetId,
833    /// Target index in the targets list.
834    pub target_index: usize,
835}
836
837// ---------------------------------------------------------------------------
838// Tests
839// ---------------------------------------------------------------------------
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    // === KeyboardDragMode tests ===
846
847    #[test]
848    fn mode_is_active() {
849        assert!(!KeyboardDragMode::Inactive.is_active());
850        assert!(KeyboardDragMode::Holding.is_active());
851        assert!(KeyboardDragMode::Navigating.is_active());
852    }
853
854    #[test]
855    fn mode_as_str() {
856        assert_eq!(KeyboardDragMode::Inactive.as_str(), "inactive");
857        assert_eq!(KeyboardDragMode::Holding.as_str(), "holding");
858        assert_eq!(KeyboardDragMode::Navigating.as_str(), "navigating");
859    }
860
861    // === Direction tests ===
862
863    #[test]
864    fn direction_opposite() {
865        assert_eq!(Direction::Up.opposite(), Direction::Down);
866        assert_eq!(Direction::Down.opposite(), Direction::Up);
867        assert_eq!(Direction::Left.opposite(), Direction::Right);
868        assert_eq!(Direction::Right.opposite(), Direction::Left);
869    }
870
871    #[test]
872    fn direction_is_vertical() {
873        assert!(Direction::Up.is_vertical());
874        assert!(Direction::Down.is_vertical());
875        assert!(!Direction::Left.is_vertical());
876        assert!(!Direction::Right.is_vertical());
877    }
878
879    // === DropTargetInfo tests ===
880
881    #[test]
882    fn drop_target_info_new() {
883        let target = DropTargetInfo::new(WidgetId(1), "Test Target", Rect::new(0, 0, 10, 5));
884        assert_eq!(target.id, WidgetId(1));
885        assert_eq!(target.name, "Test Target");
886        assert!(target.enabled);
887        assert!(target.accepted_types.is_empty());
888    }
889
890    #[test]
891    fn drop_target_info_can_accept_any() {
892        let target = DropTargetInfo::new(WidgetId(1), "Any", Rect::new(0, 0, 1, 1));
893        // No filter means accept any
894        assert!(target.can_accept("text/plain"));
895        assert!(target.can_accept("application/json"));
896    }
897
898    #[test]
899    fn drop_target_info_can_accept_filtered() {
900        let target = DropTargetInfo::new(WidgetId(1), "Text Only", Rect::new(0, 0, 1, 1))
901            .with_accepted_types(vec!["text/plain".to_string()]);
902        assert!(target.can_accept("text/plain"));
903        assert!(!target.can_accept("application/json"));
904    }
905
906    #[test]
907    fn drop_target_info_can_accept_wildcard() {
908        let target = DropTargetInfo::new(WidgetId(1), "All Text", Rect::new(0, 0, 1, 1))
909            .with_accepted_types(vec!["text/*".to_string()]);
910        assert!(target.can_accept("text/plain"));
911        assert!(target.can_accept("text/html"));
912        assert!(!target.can_accept("application/json"));
913    }
914
915    #[test]
916    fn drop_target_info_disabled() {
917        let target =
918            DropTargetInfo::new(WidgetId(1), "Disabled", Rect::new(0, 0, 1, 1)).with_enabled(false);
919        assert!(!target.can_accept("text/plain"));
920    }
921
922    #[test]
923    fn drop_target_info_center() {
924        let target = DropTargetInfo::new(WidgetId(1), "Test", Rect::new(10, 20, 10, 6));
925        assert_eq!(target.center(), (15, 23));
926    }
927
928    // === Announcement tests ===
929
930    #[test]
931    fn announcement_normal() {
932        let a = Announcement::normal("Test message");
933        assert_eq!(a.text, "Test message");
934        assert_eq!(a.priority, AnnouncementPriority::Normal);
935    }
936
937    #[test]
938    fn announcement_high() {
939        let a = Announcement::high("Important!");
940        assert_eq!(a.priority, AnnouncementPriority::High);
941    }
942
943    // === KeyboardDragConfig tests ===
944
945    #[test]
946    fn config_defaults() {
947        let config = KeyboardDragConfig::default();
948        assert!(config.cancel_on_escape);
949        assert!(config.wrap_navigation);
950        assert_eq!(config.activate_keys.len(), 2);
951    }
952
953    // === KeyboardDragState tests ===
954
955    #[test]
956    fn drag_state_animation() {
957        let payload = DragPayload::text("test");
958        let mut state = KeyboardDragState::new(WidgetId(1), payload);
959
960        let initial_tick = state.animation_tick;
961        state.tick_animation();
962        assert_eq!(state.animation_tick, initial_tick.wrapping_add(1));
963    }
964
965    #[test]
966    fn drag_state_pulse_intensity() {
967        let payload = DragPayload::text("test");
968        let state = KeyboardDragState::new(WidgetId(1), payload);
969
970        let intensity = state.pulse_intensity();
971        assert!((0.0..=1.0).contains(&intensity));
972    }
973
974    // === KeyboardDragManager tests ===
975
976    #[test]
977    fn manager_start_drag() {
978        let mut manager = KeyboardDragManager::with_defaults();
979        assert!(!manager.is_active());
980
981        let payload = DragPayload::text("item");
982        assert!(manager.start_drag(WidgetId(1), payload));
983        assert!(manager.is_active());
984        assert_eq!(manager.mode(), KeyboardDragMode::Holding);
985    }
986
987    #[test]
988    fn manager_double_start_fails() {
989        let mut manager = KeyboardDragManager::with_defaults();
990
991        assert!(manager.start_drag(WidgetId(1), DragPayload::text("first")));
992        assert!(!manager.start_drag(WidgetId(2), DragPayload::text("second")));
993    }
994
995    #[test]
996    fn manager_cancel_drag() {
997        let mut manager = KeyboardDragManager::with_defaults();
998        manager.start_drag(WidgetId(1), DragPayload::text("item"));
999
1000        let payload = manager.cancel_drag();
1001        assert!(payload.is_some());
1002        assert!(!manager.is_active());
1003    }
1004
1005    #[test]
1006    fn manager_cancel_inactive() {
1007        let mut manager = KeyboardDragManager::with_defaults();
1008        assert!(manager.cancel_drag().is_none());
1009    }
1010
1011    #[test]
1012    fn manager_navigate_targets() {
1013        let mut manager = KeyboardDragManager::with_defaults();
1014        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1015
1016        let targets = vec![
1017            DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1018            DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
1019        ];
1020
1021        let selected = manager.navigate_targets(Direction::Down, &targets);
1022        assert!(selected.is_some());
1023        assert_eq!(selected.unwrap().name, "Target A");
1024        assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1025    }
1026
1027    #[test]
1028    fn manager_navigate_empty_targets() {
1029        let mut manager = KeyboardDragManager::with_defaults();
1030        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1031
1032        let targets: Vec<DropTargetInfo> = vec![];
1033        let selected = manager.navigate_targets(Direction::Down, &targets);
1034        assert!(selected.is_none());
1035    }
1036
1037    #[test]
1038    fn manager_navigate_wrap() {
1039        let mut manager = KeyboardDragManager::with_defaults();
1040        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1041
1042        let targets = vec![
1043            DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1044            DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
1045        ];
1046
1047        // Navigate to first
1048        let _ = manager.navigate_targets(Direction::Down, &targets);
1049        // Navigate to second
1050        let _ = manager.navigate_targets(Direction::Down, &targets);
1051        // Navigate past end, should wrap to first
1052        let selected = manager.navigate_targets(Direction::Down, &targets);
1053
1054        assert_eq!(selected.unwrap().name, "Target A");
1055    }
1056
1057    #[test]
1058    fn manager_complete_drag() {
1059        let mut manager = KeyboardDragManager::with_defaults();
1060        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1061
1062        let targets = vec![DropTargetInfo::new(
1063            WidgetId(10),
1064            "Target A",
1065            Rect::new(0, 0, 10, 5),
1066        )];
1067
1068        let _ = manager.navigate_targets(Direction::Down, &targets);
1069
1070        let result = manager.complete_drag();
1071        assert!(result.is_some());
1072        let (payload, idx) = result.unwrap();
1073        assert_eq!(payload.as_text(), Some("item"));
1074        assert_eq!(idx, 0);
1075        assert!(!manager.is_active());
1076    }
1077
1078    #[test]
1079    fn manager_complete_without_target() {
1080        let mut manager = KeyboardDragManager::with_defaults();
1081        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1082
1083        // No target selected
1084        let result = manager.complete_drag();
1085        assert!(result.is_none());
1086    }
1087
1088    #[test]
1089    fn manager_handle_key_pickup() {
1090        let mut manager = KeyboardDragManager::with_defaults();
1091        let action = manager.handle_key(KeyboardDragKey::Activate);
1092        assert_eq!(action, KeyboardDragAction::PickUp);
1093    }
1094
1095    #[test]
1096    fn manager_handle_key_drop() {
1097        let mut manager = KeyboardDragManager::with_defaults();
1098        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1099
1100        // Select a target
1101        manager.state_mut().unwrap().selected_target_index = Some(0);
1102
1103        let action = manager.handle_key(KeyboardDragKey::Activate);
1104        assert_eq!(action, KeyboardDragAction::Drop);
1105    }
1106
1107    #[test]
1108    fn manager_handle_key_cancel() {
1109        let mut manager = KeyboardDragManager::with_defaults();
1110        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1111
1112        let action = manager.handle_key(KeyboardDragKey::Cancel);
1113        assert_eq!(action, KeyboardDragAction::Cancel);
1114    }
1115
1116    #[test]
1117    fn manager_handle_key_navigate() {
1118        let mut manager = KeyboardDragManager::with_defaults();
1119        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1120
1121        let action = manager.handle_key(KeyboardDragKey::Navigate(Direction::Down));
1122        assert_eq!(action, KeyboardDragAction::Navigate(Direction::Down));
1123    }
1124
1125    #[test]
1126    fn manager_announcements() {
1127        let mut manager = KeyboardDragManager::with_defaults();
1128        manager.start_drag(WidgetId(1), DragPayload::text("item"));
1129
1130        let announcements = manager.drain_announcements();
1131        assert!(!announcements.is_empty());
1132        assert!(announcements[0].text.contains("Picked up"));
1133    }
1134
1135    #[test]
1136    fn manager_announcement_queue_limit() {
1137        let config = KeyboardDragConfig {
1138            max_announcement_queue: 2,
1139            ..Default::default()
1140        };
1141        let mut manager = KeyboardDragManager::new(config);
1142
1143        // Fill queue
1144        manager.start_drag(WidgetId(1), DragPayload::text("item1"));
1145        let _ = manager.cancel_drag();
1146        manager.start_drag(WidgetId(2), DragPayload::text("item2"));
1147
1148        // Should have at most 2 announcements
1149        assert!(manager.announcements().len() <= 2);
1150    }
1151
1152    // === Target filtering tests ===
1153
1154    #[test]
1155    fn manager_navigate_skips_incompatible() {
1156        let mut manager = KeyboardDragManager::with_defaults();
1157        manager.start_drag(WidgetId(1), DragPayload::new("text/plain", vec![]));
1158
1159        let targets = vec![
1160            DropTargetInfo::new(WidgetId(10), "Text Target", Rect::new(0, 0, 10, 5))
1161                .with_accepted_types(vec!["text/plain".to_string()]),
1162            DropTargetInfo::new(WidgetId(11), "Image Target", Rect::new(20, 0, 10, 5))
1163                .with_accepted_types(vec!["image/*".to_string()]),
1164            DropTargetInfo::new(WidgetId(12), "Text Target 2", Rect::new(40, 0, 10, 5))
1165                .with_accepted_types(vec!["text/plain".to_string()]),
1166        ];
1167
1168        // First navigation should select Text Target
1169        let selected = manager.navigate_targets(Direction::Down, &targets);
1170        assert_eq!(selected.unwrap().name, "Text Target");
1171
1172        // Second navigation should skip Image Target and select Text Target 2
1173        let selected = manager.navigate_targets(Direction::Down, &targets);
1174        assert_eq!(selected.unwrap().name, "Text Target 2");
1175    }
1176
1177    // === Integration tests ===
1178
1179    #[test]
1180    fn full_keyboard_drag_lifecycle() {
1181        let mut manager = KeyboardDragManager::with_defaults();
1182
1183        // 1. Start drag
1184        assert!(manager.start_drag(WidgetId(1), DragPayload::text("dragged_item")));
1185        assert_eq!(manager.mode(), KeyboardDragMode::Holding);
1186
1187        let targets = vec![
1188            DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1189            DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(0, 10, 10, 5)),
1190        ];
1191
1192        // 2. Navigate to target
1193        let _ = manager.navigate_targets(Direction::Down, &targets);
1194        assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1195
1196        // 3. Navigate to next target
1197        let _ = manager.navigate_targets(Direction::Down, &targets);
1198
1199        // 4. Complete drop
1200        let result = manager.drop_on_target(&targets);
1201        assert!(result.is_some());
1202        let result = result.unwrap();
1203        assert_eq!(result.payload.as_text(), Some("dragged_item"));
1204        assert_eq!(result.target_id, WidgetId(11));
1205        assert_eq!(result.target_index, 1);
1206
1207        // 5. Manager is now inactive
1208        assert!(!manager.is_active());
1209    }
1210}