1#![forbid(unsafe_code)]
2
3use 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#[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 state.selected_target_index = None;
502 state.mode = KeyboardDragMode::Holding;
503 return None;
504 }
505
506 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 state.mode = KeyboardDragMode::Navigating;
523
524 let current_valid_idx = state
526 .selected_target_index
527 .and_then(|idx| valid_indices.iter().position(|&i| i == idx));
528
529 let next_valid_idx = match (current_valid_idx, direction) {
531 (None, _) => 0, (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 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 #[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 #[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 #[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 pub fn handle_key(&mut self, key: KeyboardDragKey) -> KeyboardDragAction {
656 match key {
657 KeyboardDragKey::Activate => {
658 if self.is_active() {
659 if let Some(state) = &self.state
661 && state.selected_target_index.is_some()
662 {
663 KeyboardDragAction::Drop
664 } else {
665 KeyboardDragAction::None
667 }
668 } else {
669 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 pub fn tick(&mut self) {
692 if let Some(state) = &mut self.state {
693 state.tick_animation();
694 }
695 }
696
697 pub fn drain_announcements(&mut self) -> Vec<Announcement> {
699 std::mem::take(&mut self.announcements)
700 }
701
702 #[must_use]
704 pub fn announcements(&self) -> &[Announcement] {
705 &self.announcements
706 }
707
708 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 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 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 if let Some(bg) = style.background {
763 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 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 let fg_style = Style::new().fg(style.border_fg);
786 let border_char = style.border_char;
787
788 for x in bounds.x..bounds.x.saturating_add(bounds.width) {
790 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 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 for y in
806 bounds.y.saturating_add(1)..bounds.y.saturating_add(bounds.height.saturating_sub(1))
807 {
808 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub enum KeyboardDragKey {
831 Activate,
833 Cancel,
835 Navigate(Direction),
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
845pub enum KeyboardDragAction {
846 None,
848 PickUp,
850 Navigate(Direction),
852 Drop,
854 Cancel,
856}
857
858#[derive(Debug, Clone)]
864pub struct KeyboardDropResult {
865 pub payload: DragPayload,
867 pub source_id: WidgetId,
869 pub target_id: WidgetId,
871 pub target_index: usize,
873}
874
875#[cfg(test)]
880mod tests {
881 use super::*;
882
883 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 let _ = manager.navigate_targets(Direction::Down, &targets);
1100 let _ = manager.navigate_targets(Direction::Down, &targets);
1102 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 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 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 manager.start_drag(WidgetId(1), DragPayload::text("item1"));
1229 let _ = manager.cancel_drag();
1230 manager.start_drag(WidgetId(2), DragPayload::text("item2"));
1231
1232 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 #[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 let selected = manager.navigate_targets(Direction::Down, &targets);
1287 assert_eq!(selected.unwrap().name, "Text Target");
1288
1289 let selected = manager.navigate_targets(Direction::Down, &targets);
1291 assert_eq!(selected.unwrap().name, "Text Target 2");
1292 }
1293
1294 #[test]
1297 fn full_keyboard_drag_lifecycle() {
1298 let mut manager = KeyboardDragManager::with_defaults();
1299
1300 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 let _ = manager.navigate_targets(Direction::Down, &targets);
1311 assert_eq!(manager.mode(), KeyboardDragMode::Navigating);
1312
1313 let _ = manager.navigate_targets(Direction::Down, &targets);
1315
1316 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 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}