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