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 = "use the returned state (if any)"]
458 pub fn state(&self) -> Option<&KeyboardDragState> {
459 self.state.as_ref()
460 }
461
462 #[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 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 #[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 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 state.mode = KeyboardDragMode::Navigating;
519
520 let current_valid_idx = state
522 .selected_target_index
523 .and_then(|idx| valid_indices.iter().position(|&i| i == idx));
524
525 let next_valid_idx = match (current_valid_idx, direction) {
527 (None, _) => 0, (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 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 #[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 #[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 #[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 pub fn handle_key(&mut self, key: KeyboardDragKey) -> KeyboardDragAction {
629 match key {
630 KeyboardDragKey::Activate => {
631 if self.is_active() {
632 if let Some(state) = &self.state
634 && state.selected_target_index.is_some()
635 {
636 KeyboardDragAction::Drop
637 } else {
638 KeyboardDragAction::None
640 }
641 } else {
642 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 pub fn tick(&mut self) {
665 if let Some(state) = &mut self.state {
666 state.tick_animation();
667 }
668 }
669
670 pub fn drain_announcements(&mut self) -> Vec<Announcement> {
672 std::mem::take(&mut self.announcements)
673 }
674
675 #[must_use]
677 pub fn announcements(&self) -> &[Announcement] {
678 &self.announcements
679 }
680
681 fn queue_announcement(&mut self, announcement: Announcement) {
683 if self.announcements.len() >= self.config.max_announcement_queue {
684 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 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 if let Some(bg) = style.background {
723 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 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 let fg_style = Style::new().fg(style.border_fg);
746 let border_char = style.border_char;
747
748 for x in bounds.x..bounds.x.saturating_add(bounds.width) {
750 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 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 for y in
767 bounds.y.saturating_add(1)..bounds.y.saturating_add(bounds.height.saturating_sub(1))
768 {
769 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
792pub enum KeyboardDragKey {
793 Activate,
795 Cancel,
797 Navigate(Direction),
799}
800
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
807pub enum KeyboardDragAction {
808 None,
810 PickUp,
812 Navigate(Direction),
814 Drop,
816 Cancel,
818}
819
820#[derive(Debug, Clone)]
826pub struct KeyboardDropResult {
827 pub payload: DragPayload,
829 pub source_id: WidgetId,
831 pub target_id: WidgetId,
833 pub target_index: usize,
835}
836
837#[cfg(test)]
842mod tests {
843 use super::*;
844
845 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 let _ = manager.navigate_targets(Direction::Down, &targets);
1049 let _ = manager.navigate_targets(Direction::Down, &targets);
1051 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 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 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 manager.start_drag(WidgetId(1), DragPayload::text("item1"));
1145 let _ = manager.cancel_drag();
1146 manager.start_drag(WidgetId(2), DragPayload::text("item2"));
1147
1148 assert!(manager.announcements().len() <= 2);
1150 }
1151
1152 #[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 let selected = manager.navigate_targets(Direction::Down, &targets);
1170 assert_eq!(selected.unwrap().name, "Text Target");
1171
1172 let selected = manager.navigate_targets(Direction::Down, &targets);
1174 assert_eq!(selected.unwrap().name, "Text Target 2");
1175 }
1176
1177 #[test]
1180 fn full_keyboard_drag_lifecycle() {
1181 let mut manager = KeyboardDragManager::with_defaults();
1182
1183 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 let _ = manager.navigate_targets(Direction::Down, &targets);
1194 assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1195
1196 let _ = manager.navigate_targets(Direction::Down, &targets);
1198
1199 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 assert!(!manager.is_active());
1209 }
1210}