1#![forbid(unsafe_code)]
2
3use crate::Widget;
40use crate::measure_cache::WidgetId;
41use ftui_core::geometry::Rect;
42use ftui_core::semantic_event::Position;
43use ftui_render::cell::PackedRgba;
44use ftui_render::frame::Frame;
45use ftui_style::Style;
46
47#[derive(Clone, Debug)]
67pub struct DragPayload {
68 pub drag_type: String,
70 pub data: Vec<u8>,
72 pub display_text: Option<String>,
74}
75
76impl DragPayload {
77 #[must_use]
79 pub fn new(drag_type: impl Into<String>, data: Vec<u8>) -> Self {
80 Self {
81 drag_type: drag_type.into(),
82 data,
83 display_text: None,
84 }
85 }
86
87 #[must_use]
89 pub fn text(text: impl Into<String>) -> Self {
90 let s: String = text.into();
91 let data = s.as_bytes().to_vec();
92 Self {
93 drag_type: "text/plain".to_string(),
94 data,
95 display_text: Some(s),
96 }
97 }
98
99 #[must_use]
101 pub fn with_display_text(mut self, text: impl Into<String>) -> Self {
102 self.display_text = Some(text.into());
103 self
104 }
105
106 #[must_use]
108 pub fn as_text(&self) -> Option<&str> {
109 std::str::from_utf8(&self.data).ok()
110 }
111
112 #[must_use]
114 pub fn data_len(&self) -> usize {
115 self.data.len()
116 }
117
118 #[must_use]
122 pub fn matches_type(&self, pattern: &str) -> bool {
123 if pattern == "*" || pattern == "*/*" {
124 return true;
125 }
126 if let Some(prefix) = pattern.strip_suffix("/*") {
127 self.drag_type.starts_with(prefix)
128 && self.drag_type.as_bytes().get(prefix.len()) == Some(&b'/')
129 } else {
130 self.drag_type == pattern
131 }
132 }
133}
134
135#[derive(Clone, Debug)]
143pub struct DragConfig {
144 pub threshold_cells: u16,
146 pub start_delay_ms: u64,
151 pub cancel_on_escape: bool,
153}
154
155impl Default for DragConfig {
156 fn default() -> Self {
157 Self {
158 threshold_cells: 3,
159 start_delay_ms: 0,
160 cancel_on_escape: true,
161 }
162 }
163}
164
165impl DragConfig {
166 #[must_use]
168 pub fn with_threshold(mut self, cells: u16) -> Self {
169 self.threshold_cells = cells;
170 self
171 }
172
173 #[must_use]
175 pub fn with_delay(mut self, ms: u64) -> Self {
176 self.start_delay_ms = ms;
177 self
178 }
179
180 #[must_use]
182 pub fn no_escape_cancel(mut self) -> Self {
183 self.cancel_on_escape = false;
184 self
185 }
186}
187
188pub struct DragState {
197 pub source_id: WidgetId,
199 pub payload: DragPayload,
201 pub start_pos: Position,
203 pub current_pos: Position,
205 pub preview: Option<Box<dyn Widget>>,
207}
208
209impl std::fmt::Debug for DragState {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 f.debug_struct("DragState")
212 .field("source_id", &self.source_id)
213 .field("payload", &self.payload)
214 .field("start_pos", &self.start_pos)
215 .field("current_pos", &self.current_pos)
216 .field("preview", &self.preview.as_ref().map(|_| ".."))
217 .finish()
218 }
219}
220
221impl DragState {
222 #[must_use]
224 pub fn new(source_id: WidgetId, payload: DragPayload, start_pos: Position) -> Self {
225 Self {
226 source_id,
227 payload,
228 start_pos,
229 current_pos: start_pos,
230 preview: None,
231 }
232 }
233
234 #[must_use]
236 pub fn with_preview(mut self, preview: Box<dyn Widget>) -> Self {
237 self.preview = Some(preview);
238 self
239 }
240
241 pub fn update_position(&mut self, pos: Position) {
243 self.current_pos = pos;
244 }
245
246 #[must_use]
248 pub fn distance(&self) -> u32 {
249 self.start_pos.manhattan_distance(self.current_pos)
250 }
251
252 #[must_use]
254 pub fn delta(&self) -> (i32, i32) {
255 (
256 self.current_pos.x as i32 - self.start_pos.x as i32,
257 self.current_pos.y as i32 - self.start_pos.y as i32,
258 )
259 }
260}
261
262pub trait Draggable {
288 fn drag_type(&self) -> &str;
293
294 fn drag_data(&self) -> DragPayload;
298
299 fn drag_preview(&self) -> Option<Box<dyn Widget>> {
304 None
305 }
306
307 fn drag_config(&self) -> DragConfig {
311 DragConfig::default()
312 }
313
314 fn on_drag_start(&mut self) {}
318
319 fn on_drag_end(&mut self, _success: bool) {}
324}
325
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
335pub enum DropPosition {
336 Before(usize),
338 After(usize),
340 Inside(usize),
342 Replace(usize),
344 Append,
346}
347
348impl DropPosition {
349 #[must_use]
351 pub fn index(&self) -> Option<usize> {
352 match self {
353 Self::Before(i) | Self::After(i) | Self::Inside(i) | Self::Replace(i) => Some(*i),
354 Self::Append => None,
355 }
356 }
357
358 #[must_use]
360 pub fn is_insertion(&self) -> bool {
361 matches!(self, Self::Before(_) | Self::After(_) | Self::Append)
362 }
363
364 #[must_use]
373 pub fn from_list(y: u16, item_height: u16, item_count: usize) -> Self {
374 assert!(item_height > 0, "item_height must be non-zero");
375 if item_count == 0 {
376 return Self::Append;
377 }
378 let item_index = (y / item_height) as usize;
379 if item_index >= item_count {
380 return Self::Append;
381 }
382 let within_item = y % item_height;
383 if within_item < item_height / 2 {
384 Self::Before(item_index)
385 } else {
386 Self::After(item_index)
387 }
388 }
389}
390
391#[derive(Clone, Debug, PartialEq, Eq)]
397pub enum DropResult {
398 Accepted,
400 Rejected {
402 reason: String,
404 },
405}
406
407impl DropResult {
408 #[must_use]
410 pub fn rejected(reason: impl Into<String>) -> Self {
411 Self::Rejected {
412 reason: reason.into(),
413 }
414 }
415
416 #[must_use]
418 pub fn is_accepted(&self) -> bool {
419 matches!(self, Self::Accepted)
420 }
421}
422
423pub trait DropTarget {
466 fn can_accept(&self, drag_type: &str) -> bool;
472
473 fn drop_position(&self, pos: Position, payload: &DragPayload) -> DropPosition;
478
479 fn on_drop(&mut self, payload: DragPayload, position: DropPosition) -> DropResult;
485
486 fn on_drag_enter(&mut self) {}
490
491 fn on_drag_leave(&mut self) {}
495
496 fn accepted_types(&self) -> &[&str] {
501 &[]
502 }
503}
504
505#[derive(Clone, Debug)]
513pub struct DragPreviewConfig {
514 pub opacity: f32,
517 pub offset_x: i16,
519 pub offset_y: i16,
521 pub width: u16,
523 pub height: u16,
525 pub background: Option<PackedRgba>,
527 pub show_border: bool,
529}
530
531impl Default for DragPreviewConfig {
532 fn default() -> Self {
533 Self {
534 opacity: 0.7,
535 offset_x: 1,
536 offset_y: 1,
537 width: 20,
538 height: 1,
539 background: None,
540 show_border: false,
541 }
542 }
543}
544
545impl DragPreviewConfig {
546 #[must_use]
548 pub fn with_opacity(mut self, opacity: f32) -> Self {
549 self.opacity = opacity.clamp(0.0, 1.0);
550 self
551 }
552
553 #[must_use]
555 pub fn with_offset(mut self, x: i16, y: i16) -> Self {
556 self.offset_x = x;
557 self.offset_y = y;
558 self
559 }
560
561 #[must_use]
563 pub fn with_size(mut self, width: u16, height: u16) -> Self {
564 self.width = width;
565 self.height = height;
566 self
567 }
568
569 #[must_use]
571 pub fn with_background(mut self, color: PackedRgba) -> Self {
572 self.background = Some(color);
573 self
574 }
575
576 #[must_use]
578 pub fn with_border(mut self) -> Self {
579 self.show_border = true;
580 self
581 }
582
583 #[must_use]
588 pub fn preview_rect(&self, cursor: Position, viewport: Rect) -> Option<Rect> {
589 let raw_x = cursor.x as i32 + self.offset_x as i32;
590 let raw_y = cursor.y as i32 + self.offset_y as i32;
591
592 let x = raw_x
594 .max(viewport.x as i32)
595 .min((viewport.x + viewport.width).saturating_sub(self.width) as i32);
596 let y = raw_y
597 .max(viewport.y as i32)
598 .min((viewport.y + viewport.height).saturating_sub(self.height) as i32);
599
600 if x < 0 || y < 0 {
601 return None;
602 }
603
604 let x = x as u16;
605 let y = y as u16;
606
607 if x >= viewport.x + viewport.width || y >= viewport.y + viewport.height {
609 return None;
610 }
611
612 let w = self.width.min(viewport.x + viewport.width - x);
613 let h = self.height.min(viewport.y + viewport.height - y);
614
615 if w == 0 || h == 0 {
616 return None;
617 }
618
619 Some(Rect::new(x, y, w, h))
620 }
621}
622
623pub struct DragPreview<'a> {
647 pub drag_state: &'a DragState,
649 pub config: DragPreviewConfig,
651}
652
653impl<'a> DragPreview<'a> {
654 #[must_use]
656 pub fn new(drag_state: &'a DragState) -> Self {
657 Self {
658 drag_state,
659 config: DragPreviewConfig::default(),
660 }
661 }
662
663 #[must_use]
665 pub fn with_config(drag_state: &'a DragState, config: DragPreviewConfig) -> Self {
666 Self { drag_state, config }
667 }
668
669 fn render_text_fallback(&self, area: Rect, frame: &mut Frame) {
671 let text = self
672 .drag_state
673 .payload
674 .display_text
675 .as_deref()
676 .or_else(|| self.drag_state.payload.as_text())
677 .unwrap_or("…");
678
679 crate::draw_text_span(
680 frame,
681 area.x,
682 area.y,
683 text,
684 Style::default(),
685 area.x + area.width,
686 );
687 }
688
689 fn render_border(&self, area: Rect, frame: &mut Frame) {
691 if area.width < 2 || area.height < 2 {
692 return;
693 }
694
695 let right = area.x + area.width - 1;
696 let bottom = area.y + area.height - 1;
697
698 frame
700 .buffer
701 .set(area.x, area.y, ftui_render::cell::Cell::from_char('┌'));
702 frame
703 .buffer
704 .set(right, area.y, ftui_render::cell::Cell::from_char('┐'));
705 frame
706 .buffer
707 .set(area.x, bottom, ftui_render::cell::Cell::from_char('└'));
708 frame
709 .buffer
710 .set(right, bottom, ftui_render::cell::Cell::from_char('┘'));
711
712 for x in (area.x + 1)..right {
714 frame
715 .buffer
716 .set_fast(x, area.y, ftui_render::cell::Cell::from_char('─'));
717 frame
718 .buffer
719 .set_fast(x, bottom, ftui_render::cell::Cell::from_char('─'));
720 }
721
722 for y in (area.y + 1)..bottom {
724 frame
725 .buffer
726 .set_fast(area.x, y, ftui_render::cell::Cell::from_char('│'));
727 frame
728 .buffer
729 .set_fast(right, y, ftui_render::cell::Cell::from_char('│'));
730 }
731 }
732}
733
734impl Widget for DragPreview<'_> {
735 fn render(&self, area: Rect, frame: &mut Frame) {
736 if area.is_empty() {
737 return;
738 }
739
740 if !frame.buffer.degradation.render_decorative() {
742 return;
743 }
744
745 let Some(preview_rect) = self.config.preview_rect(self.drag_state.current_pos, area) else {
746 return;
747 };
748
749 frame.buffer.push_opacity(self.config.opacity);
751
752 if let Some(bg) = self.config.background {
754 crate::set_style_area(&mut frame.buffer, preview_rect, Style::new().bg(bg));
755 }
756
757 if self.config.show_border {
760 self.render_border(preview_rect, frame);
761 }
762
763 let content_rect =
765 if self.config.show_border && preview_rect.width > 2 && preview_rect.height > 2 {
766 Rect::new(
767 preview_rect.x + 1,
768 preview_rect.y + 1,
769 preview_rect.width - 2,
770 preview_rect.height - 2,
771 )
772 } else {
773 preview_rect
774 };
775
776 if let Some(ref preview_widget) = self.drag_state.preview {
778 preview_widget.render(content_rect, frame);
779 } else {
780 self.render_text_fallback(content_rect, frame);
781 }
782
783 frame.buffer.pop_opacity();
785 }
786
787 fn is_essential(&self) -> bool {
788 false }
790}
791
792#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
803 fn payload_text_constructor() {
804 let p = DragPayload::text("hello");
805 assert_eq!(p.drag_type, "text/plain");
806 assert_eq!(p.as_text(), Some("hello"));
807 assert_eq!(p.display_text.as_deref(), Some("hello"));
808 }
809
810 #[test]
811 fn payload_raw_bytes() {
812 let p = DragPayload::new("application/octet-stream", vec![0xFF, 0xFE]);
814 assert_eq!(p.data_len(), 2);
815 assert_eq!(p.data, vec![0xFF, 0xFE]);
816 assert!(p.as_text().is_none()); }
818
819 #[test]
820 fn payload_with_display_text() {
821 let p = DragPayload::new("widget/item", vec![1, 2, 3]).with_display_text("Item #42");
822 assert_eq!(p.display_text.as_deref(), Some("Item #42"));
823 }
824
825 #[test]
826 fn payload_matches_exact_type() {
827 let p = DragPayload::text("test");
828 assert!(p.matches_type("text/plain"));
829 assert!(!p.matches_type("text/html"));
830 }
831
832 #[test]
833 fn payload_matches_wildcard() {
834 let p = DragPayload::text("test");
835 assert!(p.matches_type("text/*"));
836 assert!(p.matches_type("*/*"));
837 assert!(p.matches_type("*"));
838 assert!(!p.matches_type("application/*"));
839 }
840
841 #[test]
842 fn payload_wildcard_requires_slash() {
843 let p = DragPayload::new("textual/data", vec![]);
844 assert!(!p.matches_type("text/*"));
846 }
847
848 #[test]
849 fn payload_empty_data() {
850 let p = DragPayload::new("empty/type", vec![]);
851 assert_eq!(p.data_len(), 0);
852 assert_eq!(p.as_text(), Some(""));
853 }
854
855 #[test]
856 fn payload_clone() {
857 let p1 = DragPayload::text("hello").with_display_text("Hello!");
858 let p2 = p1.clone();
859 assert_eq!(p1.drag_type, p2.drag_type);
860 assert_eq!(p1.data, p2.data);
861 assert_eq!(p1.display_text, p2.display_text);
862 }
863
864 #[test]
867 fn config_defaults() {
868 let cfg = DragConfig::default();
869 assert_eq!(cfg.threshold_cells, 3);
870 assert_eq!(cfg.start_delay_ms, 0);
871 assert!(cfg.cancel_on_escape);
872 }
873
874 #[test]
875 fn config_builder() {
876 let cfg = DragConfig::default()
877 .with_threshold(5)
878 .with_delay(100)
879 .no_escape_cancel();
880 assert_eq!(cfg.threshold_cells, 5);
881 assert_eq!(cfg.start_delay_ms, 100);
882 assert!(!cfg.cancel_on_escape);
883 }
884
885 #[test]
888 fn drag_state_creation() {
889 let state = DragState::new(
890 WidgetId(42),
891 DragPayload::text("dragging"),
892 Position::new(10, 5),
893 );
894 assert_eq!(state.source_id, WidgetId(42));
895 assert_eq!(state.start_pos, Position::new(10, 5));
896 assert_eq!(state.current_pos, Position::new(10, 5));
897 assert!(state.preview.is_none());
898 }
899
900 #[test]
901 fn drag_state_update_position() {
902 let mut state = DragState::new(WidgetId(1), DragPayload::text("test"), Position::new(0, 0));
903 state.update_position(Position::new(5, 3));
904 assert_eq!(state.current_pos, Position::new(5, 3));
905 }
906
907 #[test]
908 fn drag_state_distance() {
909 let mut state = DragState::new(WidgetId(1), DragPayload::text("test"), Position::new(0, 0));
910 state.update_position(Position::new(3, 4));
911 assert_eq!(state.distance(), 7); }
913
914 #[test]
915 fn drag_state_delta() {
916 let mut state = DragState::new(
917 WidgetId(1),
918 DragPayload::text("test"),
919 Position::new(10, 20),
920 );
921 state.update_position(Position::new(15, 18));
922 assert_eq!(state.delta(), (5, -2));
923 }
924
925 #[test]
926 fn drag_state_zero_distance_at_start() {
927 let state = DragState::new(
928 WidgetId(1),
929 DragPayload::text("test"),
930 Position::new(50, 50),
931 );
932 assert_eq!(state.distance(), 0);
933 assert_eq!(state.delta(), (0, 0));
934 }
935
936 struct DragSourceFixture {
939 label: String,
940 started: bool,
941 ended_with: Option<bool>,
942 log: Vec<String>,
943 }
944
945 impl DragSourceFixture {
946 fn new(label: &str) -> Self {
947 Self {
948 label: label.to_string(),
949 started: false,
950 ended_with: None,
951 log: Vec::new(),
952 }
953 }
954
955 fn drain_log(&mut self) -> Vec<String> {
956 std::mem::take(&mut self.log)
957 }
958 }
959
960 impl Draggable for DragSourceFixture {
961 fn drag_type(&self) -> &str {
962 "text/plain"
963 }
964
965 fn drag_data(&self) -> DragPayload {
966 DragPayload::text(&self.label).with_display_text(&self.label)
967 }
968
969 fn on_drag_start(&mut self) {
970 self.started = true;
971 self.log.push(format!("source:start label={}", self.label));
972 }
973
974 fn on_drag_end(&mut self, success: bool) {
975 self.ended_with = Some(success);
976 self.log.push(format!(
977 "source:end label={} success={}",
978 self.label, success
979 ));
980 }
981 }
982
983 #[test]
984 fn draggable_type_and_data() {
985 let d = DragSourceFixture::new("item-1");
986 assert_eq!(d.drag_type(), "text/plain");
987 let payload = d.drag_data();
988 assert_eq!(
989 payload.as_text(),
990 Some("item-1"),
991 "payload text mismatch for fixture"
992 );
993 assert_eq!(
994 payload.display_text.as_deref(),
995 Some("item-1"),
996 "payload display_text mismatch for fixture"
997 );
998 }
999
1000 #[test]
1001 fn draggable_default_preview_is_none() {
1002 let d = DragSourceFixture::new("item");
1003 assert!(d.drag_preview().is_none());
1004 }
1005
1006 #[test]
1007 fn draggable_default_config() {
1008 let d = DragSourceFixture::new("item");
1009 let cfg = d.drag_config();
1010 assert_eq!(cfg.threshold_cells, 3);
1011 }
1012
1013 #[test]
1014 fn draggable_callbacks() {
1015 let mut d = DragSourceFixture::new("item");
1016 assert!(!d.started);
1017 assert!(d.ended_with.is_none());
1018
1019 d.on_drag_start();
1020 assert!(d.started);
1021
1022 d.on_drag_end(true);
1023 assert_eq!(d.ended_with, Some(true));
1024 assert_eq!(
1025 d.drain_log(),
1026 vec![
1027 "source:start label=item".to_string(),
1028 "source:end label=item success=true".to_string(),
1029 ],
1030 "unexpected drag log for callbacks"
1031 );
1032 }
1033
1034 #[test]
1035 fn draggable_callbacks_on_cancel() {
1036 let mut d = DragSourceFixture::new("item");
1037 d.on_drag_start();
1038 d.on_drag_end(false);
1039 assert_eq!(d.ended_with, Some(false));
1040 }
1041
1042 #[test]
1045 fn drop_position_index() {
1046 assert_eq!(DropPosition::Before(3).index(), Some(3));
1047 assert_eq!(DropPosition::After(5).index(), Some(5));
1048 assert_eq!(DropPosition::Inside(0).index(), Some(0));
1049 assert_eq!(DropPosition::Replace(7).index(), Some(7));
1050 assert_eq!(DropPosition::Append.index(), None);
1051 }
1052
1053 #[test]
1054 fn drop_position_is_insertion() {
1055 assert!(DropPosition::Before(0).is_insertion());
1056 assert!(DropPosition::After(0).is_insertion());
1057 assert!(DropPosition::Append.is_insertion());
1058 assert!(!DropPosition::Inside(0).is_insertion());
1059 assert!(!DropPosition::Replace(0).is_insertion());
1060 }
1061
1062 #[test]
1063 fn drop_position_from_list_empty() {
1064 assert_eq!(DropPosition::from_list(0, 2, 0), DropPosition::Append);
1065 }
1066
1067 #[test]
1068 fn drop_position_from_list_upper_half() {
1069 assert_eq!(DropPosition::from_list(0, 4, 3), DropPosition::Before(0));
1071 assert_eq!(DropPosition::from_list(1, 4, 3), DropPosition::Before(0));
1072 }
1073
1074 #[test]
1075 fn drop_position_from_list_lower_half() {
1076 assert_eq!(DropPosition::from_list(2, 4, 3), DropPosition::After(0));
1078 assert_eq!(DropPosition::from_list(3, 4, 3), DropPosition::After(0));
1079 }
1080
1081 #[test]
1082 fn drop_position_from_list_second_item() {
1083 assert_eq!(DropPosition::from_list(4, 4, 3), DropPosition::Before(1));
1085 assert_eq!(DropPosition::from_list(6, 4, 3), DropPosition::After(1));
1087 }
1088
1089 #[test]
1090 fn drop_position_from_list_beyond_items() {
1091 assert_eq!(DropPosition::from_list(20, 4, 3), DropPosition::Append);
1093 }
1094
1095 #[test]
1096 #[should_panic(expected = "item_height must be non-zero")]
1097 fn drop_position_from_list_zero_height_panics() {
1098 let _ = DropPosition::from_list(0, 0, 5);
1099 }
1100
1101 #[test]
1104 fn drop_result_accepted() {
1105 let r = DropResult::Accepted;
1106 assert!(r.is_accepted());
1107 }
1108
1109 #[test]
1110 fn drop_result_rejected() {
1111 let r = DropResult::rejected("type mismatch");
1112 assert!(!r.is_accepted());
1113 match r {
1114 DropResult::Rejected { reason } => assert_eq!(reason, "type mismatch"),
1115 _ => unreachable!("expected Rejected"),
1116 }
1117 }
1118
1119 #[test]
1120 fn drop_result_eq() {
1121 assert_eq!(DropResult::Accepted, DropResult::Accepted);
1122 assert_eq!(
1123 DropResult::rejected("x"),
1124 DropResult::Rejected {
1125 reason: "x".to_string()
1126 }
1127 );
1128 assert_ne!(DropResult::Accepted, DropResult::rejected("y"));
1129 }
1130
1131 struct DropListFixture {
1134 items: Vec<String>,
1135 accepted: Vec<String>,
1136 entered: bool,
1137 log: Vec<String>,
1138 }
1139
1140 impl DropListFixture {
1141 fn new(accepted: &[&str]) -> Self {
1142 Self {
1143 items: Vec::new(),
1144 accepted: accepted.iter().map(|s| s.to_string()).collect(),
1145 entered: false,
1146 log: Vec::new(),
1147 }
1148 }
1149
1150 fn drain_log(&mut self) -> Vec<String> {
1151 std::mem::take(&mut self.log)
1152 }
1153 }
1154
1155 impl DropTarget for DropListFixture {
1156 fn can_accept(&self, drag_type: &str) -> bool {
1157 self.accepted.iter().any(|t| t == drag_type)
1158 }
1159
1160 fn drop_position(&self, pos: Position, _payload: &DragPayload) -> DropPosition {
1161 if self.items.is_empty() {
1162 DropPosition::Append
1163 } else {
1164 DropPosition::from_list(pos.y, 1, self.items.len())
1165 }
1166 }
1167
1168 fn on_drop(&mut self, payload: DragPayload, position: DropPosition) -> DropResult {
1169 if let Some(text) = payload.as_text() {
1170 let idx = match position {
1171 DropPosition::Before(i) => i,
1172 DropPosition::After(i) => i + 1,
1173 DropPosition::Append => self.items.len(),
1174 _ => return DropResult::rejected("unsupported position"),
1175 };
1176 self.items.insert(idx, text.to_string());
1177 self.log
1178 .push(format!("target:drop text={text} position={position:?}"));
1179 DropResult::Accepted
1180 } else {
1181 DropResult::rejected("expected text")
1182 }
1183 }
1184
1185 fn on_drag_enter(&mut self) {
1186 self.entered = true;
1187 self.log.push("target:enter".to_string());
1188 }
1189
1190 fn on_drag_leave(&mut self) {
1191 self.entered = false;
1192 self.log.push("target:leave".to_string());
1193 }
1194
1195 fn accepted_types(&self) -> &[&str] {
1196 &[]
1197 }
1198 }
1199
1200 #[test]
1201 fn drop_target_can_accept() {
1202 let target = DropListFixture::new(&["text/plain", "widget/item"]);
1203 assert!(target.can_accept("text/plain"));
1204 assert!(target.can_accept("widget/item"));
1205 assert!(!target.can_accept("image/png"));
1206 }
1207
1208 #[test]
1209 fn drop_target_drop_position_empty() {
1210 let target = DropListFixture::new(&["text/plain"]);
1211 let pos = target.drop_position(Position::new(0, 0), &DragPayload::text("x"));
1212 assert_eq!(pos, DropPosition::Append);
1213 }
1214
1215 #[test]
1216 fn drop_target_on_drop_accepted() {
1217 let mut target = DropListFixture::new(&["text/plain"]);
1218 let result = target.on_drop(DragPayload::text("hello"), DropPosition::Append);
1219 assert!(result.is_accepted());
1220 assert_eq!(target.items, vec!["hello"]);
1221 }
1222
1223 #[test]
1224 fn drop_target_on_drop_insert_before() {
1225 let mut target = DropListFixture::new(&["text/plain"]);
1226 target.items = vec!["a".into(), "b".into()];
1227 let result = target.on_drop(DragPayload::text("x"), DropPosition::Before(1));
1228 assert!(result.is_accepted());
1229 assert_eq!(target.items, vec!["a", "x", "b"]);
1230 }
1231
1232 #[test]
1233 fn drop_target_on_drop_insert_after() {
1234 let mut target = DropListFixture::new(&["text/plain"]);
1235 target.items = vec!["a".into(), "b".into()];
1236 let result = target.on_drop(DragPayload::text("x"), DropPosition::After(0));
1237 assert!(result.is_accepted());
1238 assert_eq!(target.items, vec!["a", "x", "b"]);
1239 }
1240
1241 #[test]
1242 fn drop_target_on_drop_rejected_non_text() {
1243 let mut target = DropListFixture::new(&["application/octet-stream"]);
1244 let payload = DragPayload::new("application/octet-stream", vec![0xFF, 0xFE]);
1245 let result = target.on_drop(payload, DropPosition::Append);
1246 assert!(!result.is_accepted());
1247 }
1248
1249 #[test]
1250 fn drop_target_enter_leave() {
1251 let mut target = DropListFixture::new(&[]);
1252 assert!(!target.entered);
1253 target.on_drag_enter();
1254 assert!(target.entered);
1255 target.on_drag_leave();
1256 assert!(!target.entered);
1257 }
1258
1259 #[test]
1262 fn preview_config_defaults() {
1263 let cfg = DragPreviewConfig::default();
1264 assert!((cfg.opacity - 0.7).abs() < f32::EPSILON);
1265 assert_eq!(cfg.offset_x, 1);
1266 assert_eq!(cfg.offset_y, 1);
1267 assert_eq!(cfg.width, 20);
1268 assert_eq!(cfg.height, 1);
1269 assert!(cfg.background.is_none());
1270 assert!(!cfg.show_border);
1271 }
1272
1273 #[test]
1274 fn preview_config_builder() {
1275 let cfg = DragPreviewConfig::default()
1276 .with_opacity(0.5)
1277 .with_offset(2, 3)
1278 .with_size(30, 5)
1279 .with_background(PackedRgba::rgb(40, 40, 40))
1280 .with_border();
1281 assert!((cfg.opacity - 0.5).abs() < f32::EPSILON);
1282 assert_eq!(cfg.offset_x, 2);
1283 assert_eq!(cfg.offset_y, 3);
1284 assert_eq!(cfg.width, 30);
1285 assert_eq!(cfg.height, 5);
1286 assert!(cfg.background.is_some());
1287 assert!(cfg.show_border);
1288 }
1289
1290 #[test]
1291 fn preview_config_opacity_clamped() {
1292 let cfg = DragPreviewConfig::default().with_opacity(2.0);
1293 assert!((cfg.opacity - 1.0).abs() < f32::EPSILON);
1294 let cfg = DragPreviewConfig::default().with_opacity(-0.5);
1295 assert!((cfg.opacity - 0.0).abs() < f32::EPSILON);
1296 }
1297
1298 #[test]
1299 fn preview_rect_basic() {
1300 let cfg = DragPreviewConfig::default().with_size(10, 3);
1301 let viewport = Rect::new(0, 0, 80, 24);
1302 let cursor = Position::new(10, 5);
1303 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1304 assert_eq!(rect.x, 11); assert_eq!(rect.y, 6); assert_eq!(rect.width, 10);
1307 assert_eq!(rect.height, 3);
1308 }
1309
1310 #[test]
1311 fn preview_rect_clamped_to_right_edge() {
1312 let cfg = DragPreviewConfig::default().with_size(10, 1);
1313 let viewport = Rect::new(0, 0, 80, 24);
1314 let cursor = Position::new(75, 5);
1315 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1316 assert!(rect.x + rect.width <= 80);
1318 }
1319
1320 #[test]
1321 fn preview_rect_clamped_to_bottom_edge() {
1322 let cfg = DragPreviewConfig::default().with_size(10, 3);
1323 let viewport = Rect::new(0, 0, 80, 24);
1324 let cursor = Position::new(5, 22);
1325 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1326 assert!(rect.y + rect.height <= 24);
1327 }
1328
1329 #[test]
1330 fn preview_rect_at_origin() {
1331 let cfg = DragPreviewConfig::default()
1332 .with_offset(0, 0)
1333 .with_size(5, 2);
1334 let viewport = Rect::new(0, 0, 80, 24);
1335 let cursor = Position::new(0, 0);
1336 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1337 assert_eq!(rect.x, 0);
1338 assert_eq!(rect.y, 0);
1339 }
1340
1341 #[test]
1342 fn preview_rect_viewport_offset() {
1343 let cfg = DragPreviewConfig::default()
1344 .with_offset(-5, -5)
1345 .with_size(10, 3);
1346 let viewport = Rect::new(10, 10, 60, 14);
1347 let cursor = Position::new(12, 12);
1348 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1349 assert!(rect.x >= viewport.x);
1351 assert!(rect.y >= viewport.y);
1352 }
1353
1354 #[test]
1357 fn drag_preview_new() {
1358 let state = DragState::new(WidgetId(1), DragPayload::text("hello"), Position::new(5, 5));
1359 let preview = DragPreview::new(&state);
1360 assert!((preview.config.opacity - 0.7).abs() < f32::EPSILON);
1361 }
1362
1363 #[test]
1364 fn drag_preview_with_config() {
1365 let state = DragState::new(WidgetId(1), DragPayload::text("hello"), Position::new(5, 5));
1366 let cfg = DragPreviewConfig::default().with_opacity(0.5);
1367 let preview = DragPreview::with_config(&state, cfg);
1368 assert!((preview.config.opacity - 0.5).abs() < f32::EPSILON);
1369 }
1370
1371 #[test]
1372 fn drag_preview_is_not_essential() {
1373 let state = DragState::new(WidgetId(1), DragPayload::text("hello"), Position::new(5, 5));
1374 let preview = DragPreview::new(&state);
1375 assert!(!preview.is_essential());
1376 }
1377
1378 #[test]
1379 fn drag_preview_render_text_fallback() {
1380 use ftui_render::grapheme_pool::GraphemePool;
1381
1382 let state = DragState::new(
1383 WidgetId(1),
1384 DragPayload::text("dragged item"),
1385 Position::new(5, 5),
1386 );
1387 let preview =
1388 DragPreview::with_config(&state, DragPreviewConfig::default().with_size(20, 1));
1389
1390 let mut pool = GraphemePool::new();
1391 let mut frame = Frame::new(80, 24, &mut pool);
1392 let viewport = Rect::new(0, 0, 80, 24);
1393 preview.render(viewport, &mut frame);
1394
1395 let cell = frame.buffer.get(6, 6).unwrap();
1397 assert_eq!(cell.content.as_char(), Some('d')); }
1399
1400 #[test]
1401 fn drag_preview_render_with_border() {
1402 use ftui_render::grapheme_pool::GraphemePool;
1403
1404 let state = DragState::new(WidgetId(1), DragPayload::text("hi"), Position::new(5, 5));
1405 let preview = DragPreview::with_config(
1406 &state,
1407 DragPreviewConfig::default().with_size(10, 3).with_border(),
1408 );
1409
1410 let mut pool = GraphemePool::new();
1411 let mut frame = Frame::new(80, 24, &mut pool);
1412 let viewport = Rect::new(0, 0, 80, 24);
1413 preview.render(viewport, &mut frame);
1414
1415 let corner = frame.buffer.get(6, 6).unwrap();
1417 assert_eq!(corner.content.as_char(), Some('┌'));
1418 }
1419
1420 #[test]
1421 fn drag_preview_empty_area_noop() {
1422 use ftui_render::grapheme_pool::GraphemePool;
1423
1424 let state = DragState::new(WidgetId(1), DragPayload::text("hi"), Position::new(0, 0));
1425 let preview = DragPreview::new(&state);
1426
1427 let mut pool = GraphemePool::new();
1428 let mut frame = Frame::new(80, 24, &mut pool);
1429 preview.render(Rect::new(0, 0, 0, 0), &mut frame);
1431 }
1432
1433 fn run_drag_sequence(
1436 source: &mut DragSourceFixture,
1437 target: Option<&mut DropListFixture>,
1438 start: Position,
1439 moves: &[Position],
1440 ) -> (DragState, Option<DropResult>, Vec<String>) {
1441 let mut log = Vec::new();
1442 log.push(format!("event:start pos=({},{})", start.x, start.y));
1443
1444 source.on_drag_start();
1445 log.extend(source.drain_log());
1446
1447 let payload = source.drag_data();
1448 let mut state = DragState::new(WidgetId(99), payload, start);
1449
1450 for (idx, pos) in moves.iter().enumerate() {
1451 state.update_position(*pos);
1452 log.push(format!(
1453 "event:move#{idx} pos=({},{}) delta={:?}",
1454 pos.x,
1455 pos.y,
1456 state.delta()
1457 ));
1458 }
1459
1460 let drop_result = if let Some(target) = target {
1461 if target.can_accept(&state.payload.drag_type) {
1462 target.on_drag_enter();
1463 log.extend(target.drain_log());
1464 let pos = target.drop_position(state.current_pos, &state.payload);
1465 log.push(format!("event:drop_position={pos:?}"));
1466 let result = target.on_drop(state.payload.clone(), pos);
1467 log.extend(target.drain_log());
1468 target.on_drag_leave();
1469 log.extend(target.drain_log());
1470 source.on_drag_end(result.is_accepted());
1471 log.extend(source.drain_log());
1472 Some(result)
1473 } else {
1474 source.on_drag_end(false);
1475 log.extend(source.drain_log());
1476 None
1477 }
1478 } else {
1479 source.on_drag_end(false);
1480 log.extend(source.drain_log());
1481 None
1482 };
1483
1484 (state, drop_result, log)
1485 }
1486
1487 #[test]
1488 fn full_drag_lifecycle() {
1489 let mut source = DragSourceFixture::new("file.txt");
1490 let moves = [Position::new(10, 8), Position::new(20, 15)];
1491 let (state, result, log) =
1492 run_drag_sequence(&mut source, None, Position::new(5, 5), &moves);
1493
1494 assert!(result.is_none(), "unexpected drop result for no target");
1495 assert_eq!(state.distance(), 25, "distance mismatch after moves");
1496 assert_eq!(source.ended_with, Some(false));
1497 assert_eq!(
1498 state.payload.as_text(),
1499 Some("file.txt"),
1500 "payload text mismatch after drag"
1501 );
1502 assert_eq!(
1503 log,
1504 vec![
1505 "event:start pos=(5,5)".to_string(),
1506 "source:start label=file.txt".to_string(),
1507 "event:move#0 pos=(10,8) delta=(5, 3)".to_string(),
1508 "event:move#1 pos=(20,15) delta=(15, 10)".to_string(),
1509 "source:end label=file.txt success=false".to_string(),
1510 ],
1511 "drag log mismatch"
1512 );
1513 }
1514
1515 #[test]
1516 fn full_drag_and_drop_lifecycle() {
1517 let mut source = DragSourceFixture::new("item-A");
1518 let mut target = DropListFixture::new(&["text/plain"]);
1519 target.items = vec!["existing".into()];
1520
1521 let moves = [Position::new(10, 5)];
1522 let (_state, result, log) =
1523 run_drag_sequence(&mut source, Some(&mut target), Position::new(0, 0), &moves);
1524
1525 let result = match result {
1526 Some(result) => result,
1527 None => unreachable!("expected drop result from target"),
1528 };
1529
1530 assert!(result.is_accepted(), "drop result should be accepted");
1531 assert_eq!(source.ended_with, Some(true));
1532 assert!(!target.entered, "target should be left after drop");
1533 assert_eq!(target.items.len(), 2, "target item count mismatch");
1534 assert_eq!(
1535 log,
1536 vec![
1537 "event:start pos=(0,0)".to_string(),
1538 "source:start label=item-A".to_string(),
1539 "event:move#0 pos=(10,5) delta=(10, 5)".to_string(),
1540 "target:enter".to_string(),
1541 "event:drop_position=Append".to_string(),
1542 "target:drop text=item-A position=Append".to_string(),
1543 "target:leave".to_string(),
1544 "source:end label=item-A success=true".to_string(),
1545 ],
1546 "drag/drop log mismatch"
1547 );
1548 }
1549}