1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum KeyboardDragMode {
70 #[default]
72 Inactive,
73 Holding,
75 Navigating,
77}
78
79impl KeyboardDragMode {
80 #[must_use]
82 pub fn is_active(self) -> bool {
83 !matches!(self, Self::Inactive)
84 }
85
86 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Direction {
104 Up,
105 Down,
106 Left,
107 Right,
108}
109
110impl Direction {
111 #[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 #[must_use]
124 pub const fn is_vertical(self) -> bool {
125 matches!(self, Self::Up | Self::Down)
126 }
127}
128
129#[derive(Debug, Clone)]
135pub struct DropTargetInfo {
136 pub id: WidgetId,
138 pub name: String,
140 pub bounds: Rect,
142 pub accepted_types: Vec<String>,
144 pub enabled: bool,
146}
147
148impl DropTargetInfo {
149 #[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 #[must_use]
163 pub fn with_accepted_types(mut self, types: Vec<String>) -> Self {
164 self.accepted_types = types;
165 self
166 }
167
168 #[must_use]
170 pub fn with_enabled(mut self, enabled: bool) -> Self {
171 self.enabled = enabled;
172 self
173 }
174
175 #[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; }
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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Announcement {
213 pub text: String,
215 pub priority: AnnouncementPriority,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
221pub enum AnnouncementPriority {
222 Low,
224 #[default]
226 Normal,
227 High,
229}
230
231impl Announcement {
232 #[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 #[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#[derive(Debug, Clone)]
257pub struct KeyboardDragConfig {
258 pub activate_keys: Vec<ActivateKey>,
261
262 pub cancel_on_escape: bool,
264
265 pub target_highlight_style: TargetHighlightStyle,
267
268 pub invalid_target_style: TargetHighlightStyle,
270
271 pub wrap_navigation: bool,
273
274 pub max_announcement_queue: usize,
276}
277
278#[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#[derive(Debug, Clone)]
304pub struct TargetHighlightStyle {
305 pub border_char: char,
307 pub border_fg: PackedRgba,
309 pub background: Option<PackedRgba>,
311 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), background: Some(PackedRgba::rgba(100, 180, 255, 40)), animate_pulse: true,
322 }
323 }
324}
325
326impl TargetHighlightStyle {
327 #[must_use]
329 pub fn invalid_default() -> Self {
330 Self {
331 border_char: '▪',
332 border_fg: PackedRgba::rgb(180, 100, 100), background: Some(PackedRgba::rgba(180, 100, 100, 20)), animate_pulse: false,
335 }
336 }
337
338 #[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 #[must_use]
351 pub fn with_background(mut self, bg: PackedRgba) -> Self {
352 self.background = Some(bg);
353 self
354 }
355
356 #[must_use]
358 pub fn with_pulse(mut self) -> Self {
359 self.animate_pulse = true;
360 self
361 }
362}
363
364#[derive(Debug, Clone)]
370pub struct KeyboardDragState {
371 pub source_id: WidgetId,
373 pub payload: DragPayload,
375 pub selected_target_index: Option<usize>,
377 pub mode: KeyboardDragMode,
379 pub animation_tick: u8,
381}
382
383impl KeyboardDragState {
384 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 pub fn tick_animation(&mut self) {
397 self.animation_tick = self.animation_tick.wrapping_add(1);
398 }
399
400 #[must_use]
402 pub fn pulse_intensity(&self) -> f32 {
403 let angle = self.animation_tick as f32 * 0.15;
405 0.5 + 0.5 * angle.sin()
406 }
407}
408
409#[derive(Debug)]
415pub struct KeyboardDragManager {
416 config: KeyboardDragConfig,
418 state: Option<KeyboardDragState>,
420 announcements: Vec<Announcement>,
422}
423
424impl KeyboardDragManager {
425 #[must_use]
427 pub fn new(config: KeyboardDragConfig) -> Self {
428 Self {
429 config,
430 state: None,
431 announcements: Vec::new(),
432 }
433 }
434
435 #[must_use]
437 pub fn with_defaults() -> Self {
438 Self::new(KeyboardDragConfig::default())
439 }
440
441 #[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 #[must_use]
452 pub fn is_active(&self) -> bool {
453 self.state.is_some()
454 }
455
456 #[must_use]
458 pub fn state(&self) -> Option<&KeyboardDragState> {
459 self.state.as_ref()
460 }
461
462 pub fn state_mut(&mut self) -> Option<&mut KeyboardDragState> {
464 self.state.as_mut()
465 }
466
467 pub fn start_drag(&mut self, source_id: WidgetId, payload: DragPayload) -> bool {
472 if self.state.is_some() {
473 return false;
474 }
475
476 let description = payload
477 .display_text
478 .as_deref()
479 .or_else(|| payload.as_text())
480 .unwrap_or("item");
481
482 self.queue_announcement(Announcement::high(format!("Picked up: {description}")));
483
484 self.state = Some(KeyboardDragState::new(source_id, payload));
485 true
486 }
487
488 pub fn navigate_targets<'a>(
492 &mut self,
493 direction: Direction,
494 targets: &'a [DropTargetInfo],
495 ) -> Option<&'a DropTargetInfo> {
496 let state = self.state.as_mut()?;
497
498 if targets.is_empty() {
499 return None;
500 }
501
502 let valid_indices: Vec<usize> = targets
504 .iter()
505 .enumerate()
506 .filter(|(_, t)| t.can_accept(&state.payload.drag_type))
507 .map(|(i, _)| i)
508 .collect();
509
510 if valid_indices.is_empty() {
511 self.queue_announcement(Announcement::normal("No valid drop targets available"));
512 return None;
513 }
514
515 state.mode = KeyboardDragMode::Navigating;
517
518 let current_valid_idx = state
520 .selected_target_index
521 .and_then(|idx| valid_indices.iter().position(|&i| i == idx));
522
523 let next_valid_idx = match (current_valid_idx, direction) {
525 (None, _) => 0, (Some(idx), Direction::Down | Direction::Right) => {
527 if idx + 1 < valid_indices.len() {
528 idx + 1
529 } else if self.config.wrap_navigation {
530 0
531 } else {
532 idx
533 }
534 }
535 (Some(idx), Direction::Up | Direction::Left) => {
536 if idx > 0 {
537 idx - 1
538 } else if self.config.wrap_navigation {
539 valid_indices.len() - 1
540 } else {
541 idx
542 }
543 }
544 };
545
546 let target_idx = valid_indices[next_valid_idx];
547 state.selected_target_index = Some(target_idx);
548
549 let target = &targets[target_idx];
550 let position = format!("{} of {}", next_valid_idx + 1, valid_indices.len());
551 self.queue_announcement(Announcement::normal(format!(
552 "Drop target: {} ({})",
553 target.name, position
554 )));
555
556 Some(target)
557 }
558
559 pub fn select_target(&mut self, target_index: usize, targets: &[DropTargetInfo]) -> bool {
561 let Some(state) = self.state.as_mut() else {
562 return false;
563 };
564
565 if target_index >= targets.len() {
566 return false;
567 }
568
569 let target = &targets[target_index];
570 if !target.can_accept(&state.payload.drag_type) {
571 return false;
572 }
573
574 state.mode = KeyboardDragMode::Navigating;
575 state.selected_target_index = Some(target_index);
576
577 self.queue_announcement(Announcement::normal(format!(
578 "Drop target: {}",
579 target.name
580 )));
581 true
582 }
583
584 pub fn complete_drag(&mut self) -> Option<(DragPayload, usize)> {
589 let state = self.state.take()?;
590 let target_idx = state.selected_target_index?;
591
592 Some((state.payload, target_idx))
593 }
594
595 pub fn drop_on_target(&mut self, targets: &[DropTargetInfo]) -> Option<KeyboardDropResult> {
597 let state = self.state.take()?;
598 let target_idx = state.selected_target_index?;
599 let target = targets.get(target_idx)?;
600
601 self.queue_announcement(Announcement::high(format!("Dropped on: {}", target.name)));
602
603 Some(KeyboardDropResult {
604 payload: state.payload,
605 source_id: state.source_id,
606 target_id: target.id,
607 target_index: target_idx,
608 })
609 }
610
611 pub fn cancel_drag(&mut self) -> Option<DragPayload> {
615 let state = self.state.take()?;
616 self.queue_announcement(Announcement::normal("Drop cancelled"));
617 Some(state.payload)
618 }
619
620 pub fn handle_key(&mut self, key: KeyboardDragKey) -> KeyboardDragAction {
624 match key {
625 KeyboardDragKey::Activate => {
626 if self.is_active() {
627 if let Some(state) = &self.state
629 && state.selected_target_index.is_some()
630 {
631 KeyboardDragAction::Drop
632 } else {
633 KeyboardDragAction::None
635 }
636 } else {
637 KeyboardDragAction::PickUp
639 }
640 }
641 KeyboardDragKey::Cancel => {
642 if self.is_active() && self.config.cancel_on_escape {
643 KeyboardDragAction::Cancel
644 } else {
645 KeyboardDragAction::None
646 }
647 }
648 KeyboardDragKey::Navigate(dir) => {
649 if self.is_active() {
650 KeyboardDragAction::Navigate(dir)
651 } else {
652 KeyboardDragAction::None
653 }
654 }
655 }
656 }
657
658 pub fn tick(&mut self) {
660 if let Some(state) = &mut self.state {
661 state.tick_animation();
662 }
663 }
664
665 pub fn drain_announcements(&mut self) -> Vec<Announcement> {
667 std::mem::take(&mut self.announcements)
668 }
669
670 #[must_use]
672 pub fn announcements(&self) -> &[Announcement] {
673 &self.announcements
674 }
675
676 fn queue_announcement(&mut self, announcement: Announcement) {
678 if self.announcements.len() >= self.config.max_announcement_queue {
679 if let Some(pos) = self
681 .announcements
682 .iter()
683 .enumerate()
684 .min_by_key(|(_, a)| a.priority)
685 .map(|(i, _)| i)
686 {
687 self.announcements.remove(pos);
688 }
689 }
690 self.announcements.push(announcement);
691 }
692
693 pub fn render_highlight(&self, targets: &[DropTargetInfo], frame: &mut Frame) {
695 let Some(state) = &self.state else {
696 return;
697 };
698 let Some(target_idx) = state.selected_target_index else {
699 return;
700 };
701 let Some(target) = targets.get(target_idx) else {
702 return;
703 };
704
705 let style = if target.can_accept(&state.payload.drag_type) {
706 &self.config.target_highlight_style
707 } else {
708 &self.config.invalid_target_style
709 };
710
711 let bounds = target.bounds;
712 if bounds.is_empty() {
713 return;
714 }
715
716 if let Some(bg) = style.background {
718 let alpha = if style.animate_pulse {
720 let base_alpha = (bg.0 & 0xFF) as f32 / 255.0;
721 let pulsed = base_alpha * (0.5 + 0.5 * state.pulse_intensity());
722 (pulsed * 255.0) as u8
723 } else {
724 (bg.0 & 0xFF) as u8
725 };
726
727 let effective_bg = PackedRgba((bg.0 & 0xFFFF_FF00) | alpha as u32);
728
729 for y in bounds.y..bounds.y.saturating_add(bounds.height) {
731 for x in bounds.x..bounds.x.saturating_add(bounds.width) {
732 if let Some(cell) = frame.buffer.get_mut(x, y) {
733 cell.bg = effective_bg;
734 }
735 }
736 }
737 }
738
739 let fg_style = Style::new().fg(style.border_fg);
741 let border_char = style.border_char;
742
743 for x in bounds.x..bounds.x.saturating_add(bounds.width) {
745 if let Some(cell) = frame.buffer.get_mut(x, bounds.y) {
747 cell.content = CellContent::from_char(border_char);
748 cell.fg = fg_style.fg.unwrap_or(style.border_fg);
749 }
750 let bottom_y = bounds.y.saturating_add(bounds.height.saturating_sub(1));
752 if bounds.height > 1
753 && let Some(cell) = frame.buffer.get_mut(x, bottom_y)
754 {
755 cell.content = CellContent::from_char(border_char);
756 cell.fg = fg_style.fg.unwrap_or(style.border_fg);
757 }
758 }
759
760 for y in
762 bounds.y.saturating_add(1)..bounds.y.saturating_add(bounds.height.saturating_sub(1))
763 {
764 if let Some(cell) = frame.buffer.get_mut(bounds.x, y) {
766 cell.content = CellContent::from_char(border_char);
767 cell.fg = fg_style.fg.unwrap_or(style.border_fg);
768 }
769 let right_x = bounds.x.saturating_add(bounds.width.saturating_sub(1));
771 if bounds.width > 1
772 && let Some(cell) = frame.buffer.get_mut(right_x, y)
773 {
774 cell.content = CellContent::from_char(border_char);
775 cell.fg = fg_style.fg.unwrap_or(style.border_fg);
776 }
777 }
778 }
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub enum KeyboardDragKey {
788 Activate,
790 Cancel,
792 Navigate(Direction),
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum KeyboardDragAction {
803 None,
805 PickUp,
807 Navigate(Direction),
809 Drop,
811 Cancel,
813}
814
815#[derive(Debug, Clone)]
821pub struct KeyboardDropResult {
822 pub payload: DragPayload,
824 pub source_id: WidgetId,
826 pub target_id: WidgetId,
828 pub target_index: usize,
830}
831
832#[cfg(test)]
837mod tests {
838 use super::*;
839
840 #[test]
843 fn mode_is_active() {
844 assert!(!KeyboardDragMode::Inactive.is_active());
845 assert!(KeyboardDragMode::Holding.is_active());
846 assert!(KeyboardDragMode::Navigating.is_active());
847 }
848
849 #[test]
850 fn mode_as_str() {
851 assert_eq!(KeyboardDragMode::Inactive.as_str(), "inactive");
852 assert_eq!(KeyboardDragMode::Holding.as_str(), "holding");
853 assert_eq!(KeyboardDragMode::Navigating.as_str(), "navigating");
854 }
855
856 #[test]
859 fn direction_opposite() {
860 assert_eq!(Direction::Up.opposite(), Direction::Down);
861 assert_eq!(Direction::Down.opposite(), Direction::Up);
862 assert_eq!(Direction::Left.opposite(), Direction::Right);
863 assert_eq!(Direction::Right.opposite(), Direction::Left);
864 }
865
866 #[test]
867 fn direction_is_vertical() {
868 assert!(Direction::Up.is_vertical());
869 assert!(Direction::Down.is_vertical());
870 assert!(!Direction::Left.is_vertical());
871 assert!(!Direction::Right.is_vertical());
872 }
873
874 #[test]
877 fn drop_target_info_new() {
878 let target = DropTargetInfo::new(WidgetId(1), "Test Target", Rect::new(0, 0, 10, 5));
879 assert_eq!(target.id, WidgetId(1));
880 assert_eq!(target.name, "Test Target");
881 assert!(target.enabled);
882 assert!(target.accepted_types.is_empty());
883 }
884
885 #[test]
886 fn drop_target_info_can_accept_any() {
887 let target = DropTargetInfo::new(WidgetId(1), "Any", Rect::new(0, 0, 1, 1));
888 assert!(target.can_accept("text/plain"));
890 assert!(target.can_accept("application/json"));
891 }
892
893 #[test]
894 fn drop_target_info_can_accept_filtered() {
895 let target = DropTargetInfo::new(WidgetId(1), "Text Only", Rect::new(0, 0, 1, 1))
896 .with_accepted_types(vec!["text/plain".to_string()]);
897 assert!(target.can_accept("text/plain"));
898 assert!(!target.can_accept("application/json"));
899 }
900
901 #[test]
902 fn drop_target_info_can_accept_wildcard() {
903 let target = DropTargetInfo::new(WidgetId(1), "All Text", Rect::new(0, 0, 1, 1))
904 .with_accepted_types(vec!["text/*".to_string()]);
905 assert!(target.can_accept("text/plain"));
906 assert!(target.can_accept("text/html"));
907 assert!(!target.can_accept("application/json"));
908 }
909
910 #[test]
911 fn drop_target_info_disabled() {
912 let target =
913 DropTargetInfo::new(WidgetId(1), "Disabled", Rect::new(0, 0, 1, 1)).with_enabled(false);
914 assert!(!target.can_accept("text/plain"));
915 }
916
917 #[test]
918 fn drop_target_info_center() {
919 let target = DropTargetInfo::new(WidgetId(1), "Test", Rect::new(10, 20, 10, 6));
920 assert_eq!(target.center(), (15, 23));
921 }
922
923 #[test]
926 fn announcement_normal() {
927 let a = Announcement::normal("Test message");
928 assert_eq!(a.text, "Test message");
929 assert_eq!(a.priority, AnnouncementPriority::Normal);
930 }
931
932 #[test]
933 fn announcement_high() {
934 let a = Announcement::high("Important!");
935 assert_eq!(a.priority, AnnouncementPriority::High);
936 }
937
938 #[test]
941 fn config_defaults() {
942 let config = KeyboardDragConfig::default();
943 assert!(config.cancel_on_escape);
944 assert!(config.wrap_navigation);
945 assert_eq!(config.activate_keys.len(), 2);
946 }
947
948 #[test]
951 fn drag_state_animation() {
952 let payload = DragPayload::text("test");
953 let mut state = KeyboardDragState::new(WidgetId(1), payload);
954
955 let initial_tick = state.animation_tick;
956 state.tick_animation();
957 assert_eq!(state.animation_tick, initial_tick.wrapping_add(1));
958 }
959
960 #[test]
961 fn drag_state_pulse_intensity() {
962 let payload = DragPayload::text("test");
963 let state = KeyboardDragState::new(WidgetId(1), payload);
964
965 let intensity = state.pulse_intensity();
966 assert!((0.0..=1.0).contains(&intensity));
967 }
968
969 #[test]
972 fn manager_start_drag() {
973 let mut manager = KeyboardDragManager::with_defaults();
974 assert!(!manager.is_active());
975
976 let payload = DragPayload::text("item");
977 assert!(manager.start_drag(WidgetId(1), payload));
978 assert!(manager.is_active());
979 assert_eq!(manager.mode(), KeyboardDragMode::Holding);
980 }
981
982 #[test]
983 fn manager_double_start_fails() {
984 let mut manager = KeyboardDragManager::with_defaults();
985
986 assert!(manager.start_drag(WidgetId(1), DragPayload::text("first")));
987 assert!(!manager.start_drag(WidgetId(2), DragPayload::text("second")));
988 }
989
990 #[test]
991 fn manager_cancel_drag() {
992 let mut manager = KeyboardDragManager::with_defaults();
993 manager.start_drag(WidgetId(1), DragPayload::text("item"));
994
995 let payload = manager.cancel_drag();
996 assert!(payload.is_some());
997 assert!(!manager.is_active());
998 }
999
1000 #[test]
1001 fn manager_cancel_inactive() {
1002 let mut manager = KeyboardDragManager::with_defaults();
1003 assert!(manager.cancel_drag().is_none());
1004 }
1005
1006 #[test]
1007 fn manager_navigate_targets() {
1008 let mut manager = KeyboardDragManager::with_defaults();
1009 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1010
1011 let targets = vec![
1012 DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1013 DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
1014 ];
1015
1016 let selected = manager.navigate_targets(Direction::Down, &targets);
1017 assert!(selected.is_some());
1018 assert_eq!(selected.unwrap().name, "Target A");
1019 assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1020 }
1021
1022 #[test]
1023 fn manager_navigate_empty_targets() {
1024 let mut manager = KeyboardDragManager::with_defaults();
1025 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1026
1027 let targets: Vec<DropTargetInfo> = vec![];
1028 let selected = manager.navigate_targets(Direction::Down, &targets);
1029 assert!(selected.is_none());
1030 }
1031
1032 #[test]
1033 fn manager_navigate_wrap() {
1034 let mut manager = KeyboardDragManager::with_defaults();
1035 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1036
1037 let targets = vec![
1038 DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1039 DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(20, 0, 10, 5)),
1040 ];
1041
1042 manager.navigate_targets(Direction::Down, &targets);
1044 manager.navigate_targets(Direction::Down, &targets);
1046 let selected = manager.navigate_targets(Direction::Down, &targets);
1048
1049 assert_eq!(selected.unwrap().name, "Target A");
1050 }
1051
1052 #[test]
1053 fn manager_complete_drag() {
1054 let mut manager = KeyboardDragManager::with_defaults();
1055 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1056
1057 let targets = vec![DropTargetInfo::new(
1058 WidgetId(10),
1059 "Target A",
1060 Rect::new(0, 0, 10, 5),
1061 )];
1062
1063 manager.navigate_targets(Direction::Down, &targets);
1064
1065 let result = manager.complete_drag();
1066 assert!(result.is_some());
1067 let (payload, idx) = result.unwrap();
1068 assert_eq!(payload.as_text(), Some("item"));
1069 assert_eq!(idx, 0);
1070 assert!(!manager.is_active());
1071 }
1072
1073 #[test]
1074 fn manager_complete_without_target() {
1075 let mut manager = KeyboardDragManager::with_defaults();
1076 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1077
1078 let result = manager.complete_drag();
1080 assert!(result.is_none());
1081 }
1082
1083 #[test]
1084 fn manager_handle_key_pickup() {
1085 let mut manager = KeyboardDragManager::with_defaults();
1086 let action = manager.handle_key(KeyboardDragKey::Activate);
1087 assert_eq!(action, KeyboardDragAction::PickUp);
1088 }
1089
1090 #[test]
1091 fn manager_handle_key_drop() {
1092 let mut manager = KeyboardDragManager::with_defaults();
1093 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1094
1095 manager.state_mut().unwrap().selected_target_index = Some(0);
1097
1098 let action = manager.handle_key(KeyboardDragKey::Activate);
1099 assert_eq!(action, KeyboardDragAction::Drop);
1100 }
1101
1102 #[test]
1103 fn manager_handle_key_cancel() {
1104 let mut manager = KeyboardDragManager::with_defaults();
1105 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1106
1107 let action = manager.handle_key(KeyboardDragKey::Cancel);
1108 assert_eq!(action, KeyboardDragAction::Cancel);
1109 }
1110
1111 #[test]
1112 fn manager_handle_key_navigate() {
1113 let mut manager = KeyboardDragManager::with_defaults();
1114 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1115
1116 let action = manager.handle_key(KeyboardDragKey::Navigate(Direction::Down));
1117 assert_eq!(action, KeyboardDragAction::Navigate(Direction::Down));
1118 }
1119
1120 #[test]
1121 fn manager_announcements() {
1122 let mut manager = KeyboardDragManager::with_defaults();
1123 manager.start_drag(WidgetId(1), DragPayload::text("item"));
1124
1125 let announcements = manager.drain_announcements();
1126 assert!(!announcements.is_empty());
1127 assert!(announcements[0].text.contains("Picked up"));
1128 }
1129
1130 #[test]
1131 fn manager_announcement_queue_limit() {
1132 let config = KeyboardDragConfig {
1133 max_announcement_queue: 2,
1134 ..Default::default()
1135 };
1136 let mut manager = KeyboardDragManager::new(config);
1137
1138 manager.start_drag(WidgetId(1), DragPayload::text("item1"));
1140 manager.cancel_drag();
1141 manager.start_drag(WidgetId(2), DragPayload::text("item2"));
1142
1143 assert!(manager.announcements().len() <= 2);
1145 }
1146
1147 #[test]
1150 fn manager_navigate_skips_incompatible() {
1151 let mut manager = KeyboardDragManager::with_defaults();
1152 manager.start_drag(WidgetId(1), DragPayload::new("text/plain", vec![]));
1153
1154 let targets = vec![
1155 DropTargetInfo::new(WidgetId(10), "Text Target", Rect::new(0, 0, 10, 5))
1156 .with_accepted_types(vec!["text/plain".to_string()]),
1157 DropTargetInfo::new(WidgetId(11), "Image Target", Rect::new(20, 0, 10, 5))
1158 .with_accepted_types(vec!["image/*".to_string()]),
1159 DropTargetInfo::new(WidgetId(12), "Text Target 2", Rect::new(40, 0, 10, 5))
1160 .with_accepted_types(vec!["text/plain".to_string()]),
1161 ];
1162
1163 let selected = manager.navigate_targets(Direction::Down, &targets);
1165 assert_eq!(selected.unwrap().name, "Text Target");
1166
1167 let selected = manager.navigate_targets(Direction::Down, &targets);
1169 assert_eq!(selected.unwrap().name, "Text Target 2");
1170 }
1171
1172 #[test]
1175 fn full_keyboard_drag_lifecycle() {
1176 let mut manager = KeyboardDragManager::with_defaults();
1177
1178 assert!(manager.start_drag(WidgetId(1), DragPayload::text("dragged_item")));
1180 assert_eq!(manager.mode(), KeyboardDragMode::Holding);
1181
1182 let targets = vec![
1183 DropTargetInfo::new(WidgetId(10), "Target A", Rect::new(0, 0, 10, 5)),
1184 DropTargetInfo::new(WidgetId(11), "Target B", Rect::new(0, 10, 10, 5)),
1185 ];
1186
1187 manager.navigate_targets(Direction::Down, &targets);
1189 assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1190
1191 manager.navigate_targets(Direction::Down, &targets);
1193
1194 let result = manager.drop_on_target(&targets);
1196 assert!(result.is_some());
1197 let result = result.unwrap();
1198 assert_eq!(result.payload.as_text(), Some("dragged_item"));
1199 assert_eq!(result.target_id, WidgetId(11));
1200 assert_eq!(result.target_index, 1);
1201
1202 assert!(!manager.is_active());
1204 }
1205}