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 let max_chars = area.width as usize;
681 let display: String = text.chars().take(max_chars).collect();
682
683 crate::draw_text_span(
684 frame,
685 area.x,
686 area.y,
687 &display,
688 Style::default(),
689 area.x + area.width,
690 );
691 }
692
693 fn render_border(&self, area: Rect, frame: &mut Frame) {
695 if area.width < 2 || area.height < 2 {
696 return;
697 }
698
699 let right = area.x + area.width - 1;
700 let bottom = area.y + area.height - 1;
701
702 frame
704 .buffer
705 .set(area.x, area.y, ftui_render::cell::Cell::from_char('┌'));
706 frame
707 .buffer
708 .set(right, area.y, ftui_render::cell::Cell::from_char('┐'));
709 frame
710 .buffer
711 .set(area.x, bottom, ftui_render::cell::Cell::from_char('└'));
712 frame
713 .buffer
714 .set(right, bottom, ftui_render::cell::Cell::from_char('┘'));
715
716 for x in (area.x + 1)..right {
718 frame
719 .buffer
720 .set_fast(x, area.y, ftui_render::cell::Cell::from_char('─'));
721 frame
722 .buffer
723 .set_fast(x, bottom, ftui_render::cell::Cell::from_char('─'));
724 }
725
726 for y in (area.y + 1)..bottom {
728 frame
729 .buffer
730 .set_fast(area.x, y, ftui_render::cell::Cell::from_char('│'));
731 frame
732 .buffer
733 .set_fast(right, y, ftui_render::cell::Cell::from_char('│'));
734 }
735 }
736}
737
738impl Widget for DragPreview<'_> {
739 fn render(&self, area: Rect, frame: &mut Frame) {
740 if area.is_empty() {
741 return;
742 }
743
744 if !frame.buffer.degradation.render_decorative() {
746 return;
747 }
748
749 let Some(preview_rect) = self.config.preview_rect(self.drag_state.current_pos, area) else {
750 return;
751 };
752
753 frame.buffer.push_opacity(self.config.opacity);
755
756 if let Some(bg) = self.config.background {
758 crate::set_style_area(&mut frame.buffer, preview_rect, Style::new().bg(bg));
759 }
760
761 if self.config.show_border {
764 self.render_border(preview_rect, frame);
765 }
766
767 let content_rect =
769 if self.config.show_border && preview_rect.width > 2 && preview_rect.height > 2 {
770 Rect::new(
771 preview_rect.x + 1,
772 preview_rect.y + 1,
773 preview_rect.width - 2,
774 preview_rect.height - 2,
775 )
776 } else {
777 preview_rect
778 };
779
780 if let Some(ref preview_widget) = self.drag_state.preview {
782 preview_widget.render(content_rect, frame);
783 } else {
784 self.render_text_fallback(content_rect, frame);
785 }
786
787 frame.buffer.pop_opacity();
789 }
790
791 fn is_essential(&self) -> bool {
792 false }
794}
795
796#[cfg(test)]
801mod tests {
802 use super::*;
803
804 #[test]
807 fn payload_text_constructor() {
808 let p = DragPayload::text("hello");
809 assert_eq!(p.drag_type, "text/plain");
810 assert_eq!(p.as_text(), Some("hello"));
811 assert_eq!(p.display_text.as_deref(), Some("hello"));
812 }
813
814 #[test]
815 fn payload_raw_bytes() {
816 let p = DragPayload::new("application/octet-stream", vec![0xFF, 0xFE]);
818 assert_eq!(p.data_len(), 2);
819 assert_eq!(p.data, vec![0xFF, 0xFE]);
820 assert!(p.as_text().is_none()); }
822
823 #[test]
824 fn payload_with_display_text() {
825 let p = DragPayload::new("widget/item", vec![1, 2, 3]).with_display_text("Item #42");
826 assert_eq!(p.display_text.as_deref(), Some("Item #42"));
827 }
828
829 #[test]
830 fn payload_matches_exact_type() {
831 let p = DragPayload::text("test");
832 assert!(p.matches_type("text/plain"));
833 assert!(!p.matches_type("text/html"));
834 }
835
836 #[test]
837 fn payload_matches_wildcard() {
838 let p = DragPayload::text("test");
839 assert!(p.matches_type("text/*"));
840 assert!(p.matches_type("*/*"));
841 assert!(p.matches_type("*"));
842 assert!(!p.matches_type("application/*"));
843 }
844
845 #[test]
846 fn payload_wildcard_requires_slash() {
847 let p = DragPayload::new("textual/data", vec![]);
848 assert!(!p.matches_type("text/*"));
850 }
851
852 #[test]
853 fn payload_empty_data() {
854 let p = DragPayload::new("empty/type", vec![]);
855 assert_eq!(p.data_len(), 0);
856 assert_eq!(p.as_text(), Some(""));
857 }
858
859 #[test]
860 fn payload_clone() {
861 let p1 = DragPayload::text("hello").with_display_text("Hello!");
862 let p2 = p1.clone();
863 assert_eq!(p1.drag_type, p2.drag_type);
864 assert_eq!(p1.data, p2.data);
865 assert_eq!(p1.display_text, p2.display_text);
866 }
867
868 #[test]
871 fn config_defaults() {
872 let cfg = DragConfig::default();
873 assert_eq!(cfg.threshold_cells, 3);
874 assert_eq!(cfg.start_delay_ms, 0);
875 assert!(cfg.cancel_on_escape);
876 }
877
878 #[test]
879 fn config_builder() {
880 let cfg = DragConfig::default()
881 .with_threshold(5)
882 .with_delay(100)
883 .no_escape_cancel();
884 assert_eq!(cfg.threshold_cells, 5);
885 assert_eq!(cfg.start_delay_ms, 100);
886 assert!(!cfg.cancel_on_escape);
887 }
888
889 #[test]
892 fn drag_state_creation() {
893 let state = DragState::new(
894 WidgetId(42),
895 DragPayload::text("dragging"),
896 Position::new(10, 5),
897 );
898 assert_eq!(state.source_id, WidgetId(42));
899 assert_eq!(state.start_pos, Position::new(10, 5));
900 assert_eq!(state.current_pos, Position::new(10, 5));
901 assert!(state.preview.is_none());
902 }
903
904 #[test]
905 fn drag_state_update_position() {
906 let mut state = DragState::new(WidgetId(1), DragPayload::text("test"), Position::new(0, 0));
907 state.update_position(Position::new(5, 3));
908 assert_eq!(state.current_pos, Position::new(5, 3));
909 }
910
911 #[test]
912 fn drag_state_distance() {
913 let mut state = DragState::new(WidgetId(1), DragPayload::text("test"), Position::new(0, 0));
914 state.update_position(Position::new(3, 4));
915 assert_eq!(state.distance(), 7); }
917
918 #[test]
919 fn drag_state_delta() {
920 let mut state = DragState::new(
921 WidgetId(1),
922 DragPayload::text("test"),
923 Position::new(10, 20),
924 );
925 state.update_position(Position::new(15, 18));
926 assert_eq!(state.delta(), (5, -2));
927 }
928
929 #[test]
930 fn drag_state_zero_distance_at_start() {
931 let state = DragState::new(
932 WidgetId(1),
933 DragPayload::text("test"),
934 Position::new(50, 50),
935 );
936 assert_eq!(state.distance(), 0);
937 assert_eq!(state.delta(), (0, 0));
938 }
939
940 struct DragSourceFixture {
943 label: String,
944 started: bool,
945 ended_with: Option<bool>,
946 log: Vec<String>,
947 }
948
949 impl DragSourceFixture {
950 fn new(label: &str) -> Self {
951 Self {
952 label: label.to_string(),
953 started: false,
954 ended_with: None,
955 log: Vec::new(),
956 }
957 }
958
959 fn drain_log(&mut self) -> Vec<String> {
960 std::mem::take(&mut self.log)
961 }
962 }
963
964 impl Draggable for DragSourceFixture {
965 fn drag_type(&self) -> &str {
966 "text/plain"
967 }
968
969 fn drag_data(&self) -> DragPayload {
970 DragPayload::text(&self.label).with_display_text(&self.label)
971 }
972
973 fn on_drag_start(&mut self) {
974 self.started = true;
975 self.log.push(format!("source:start label={}", self.label));
976 }
977
978 fn on_drag_end(&mut self, success: bool) {
979 self.ended_with = Some(success);
980 self.log.push(format!(
981 "source:end label={} success={}",
982 self.label, success
983 ));
984 }
985 }
986
987 #[test]
988 fn draggable_type_and_data() {
989 let d = DragSourceFixture::new("item-1");
990 assert_eq!(d.drag_type(), "text/plain");
991 let payload = d.drag_data();
992 assert_eq!(
993 payload.as_text(),
994 Some("item-1"),
995 "payload text mismatch for fixture"
996 );
997 assert_eq!(
998 payload.display_text.as_deref(),
999 Some("item-1"),
1000 "payload display_text mismatch for fixture"
1001 );
1002 }
1003
1004 #[test]
1005 fn draggable_default_preview_is_none() {
1006 let d = DragSourceFixture::new("item");
1007 assert!(d.drag_preview().is_none());
1008 }
1009
1010 #[test]
1011 fn draggable_default_config() {
1012 let d = DragSourceFixture::new("item");
1013 let cfg = d.drag_config();
1014 assert_eq!(cfg.threshold_cells, 3);
1015 }
1016
1017 #[test]
1018 fn draggable_callbacks() {
1019 let mut d = DragSourceFixture::new("item");
1020 assert!(!d.started);
1021 assert!(d.ended_with.is_none());
1022
1023 d.on_drag_start();
1024 assert!(d.started);
1025
1026 d.on_drag_end(true);
1027 assert_eq!(d.ended_with, Some(true));
1028 assert_eq!(
1029 d.drain_log(),
1030 vec![
1031 "source:start label=item".to_string(),
1032 "source:end label=item success=true".to_string(),
1033 ],
1034 "unexpected drag log for callbacks"
1035 );
1036 }
1037
1038 #[test]
1039 fn draggable_callbacks_on_cancel() {
1040 let mut d = DragSourceFixture::new("item");
1041 d.on_drag_start();
1042 d.on_drag_end(false);
1043 assert_eq!(d.ended_with, Some(false));
1044 }
1045
1046 #[test]
1049 fn drop_position_index() {
1050 assert_eq!(DropPosition::Before(3).index(), Some(3));
1051 assert_eq!(DropPosition::After(5).index(), Some(5));
1052 assert_eq!(DropPosition::Inside(0).index(), Some(0));
1053 assert_eq!(DropPosition::Replace(7).index(), Some(7));
1054 assert_eq!(DropPosition::Append.index(), None);
1055 }
1056
1057 #[test]
1058 fn drop_position_is_insertion() {
1059 assert!(DropPosition::Before(0).is_insertion());
1060 assert!(DropPosition::After(0).is_insertion());
1061 assert!(DropPosition::Append.is_insertion());
1062 assert!(!DropPosition::Inside(0).is_insertion());
1063 assert!(!DropPosition::Replace(0).is_insertion());
1064 }
1065
1066 #[test]
1067 fn drop_position_from_list_empty() {
1068 assert_eq!(DropPosition::from_list(0, 2, 0), DropPosition::Append);
1069 }
1070
1071 #[test]
1072 fn drop_position_from_list_upper_half() {
1073 assert_eq!(DropPosition::from_list(0, 4, 3), DropPosition::Before(0));
1075 assert_eq!(DropPosition::from_list(1, 4, 3), DropPosition::Before(0));
1076 }
1077
1078 #[test]
1079 fn drop_position_from_list_lower_half() {
1080 assert_eq!(DropPosition::from_list(2, 4, 3), DropPosition::After(0));
1082 assert_eq!(DropPosition::from_list(3, 4, 3), DropPosition::After(0));
1083 }
1084
1085 #[test]
1086 fn drop_position_from_list_second_item() {
1087 assert_eq!(DropPosition::from_list(4, 4, 3), DropPosition::Before(1));
1089 assert_eq!(DropPosition::from_list(6, 4, 3), DropPosition::After(1));
1091 }
1092
1093 #[test]
1094 fn drop_position_from_list_beyond_items() {
1095 assert_eq!(DropPosition::from_list(20, 4, 3), DropPosition::Append);
1097 }
1098
1099 #[test]
1100 #[should_panic(expected = "item_height must be non-zero")]
1101 fn drop_position_from_list_zero_height_panics() {
1102 let _ = DropPosition::from_list(0, 0, 5);
1103 }
1104
1105 #[test]
1108 fn drop_result_accepted() {
1109 let r = DropResult::Accepted;
1110 assert!(r.is_accepted());
1111 }
1112
1113 #[test]
1114 fn drop_result_rejected() {
1115 let r = DropResult::rejected("type mismatch");
1116 assert!(!r.is_accepted());
1117 match r {
1118 DropResult::Rejected { reason } => assert_eq!(reason, "type mismatch"),
1119 _ => unreachable!("expected Rejected"),
1120 }
1121 }
1122
1123 #[test]
1124 fn drop_result_eq() {
1125 assert_eq!(DropResult::Accepted, DropResult::Accepted);
1126 assert_eq!(
1127 DropResult::rejected("x"),
1128 DropResult::Rejected {
1129 reason: "x".to_string()
1130 }
1131 );
1132 assert_ne!(DropResult::Accepted, DropResult::rejected("y"));
1133 }
1134
1135 struct DropListFixture {
1138 items: Vec<String>,
1139 accepted: Vec<String>,
1140 entered: bool,
1141 log: Vec<String>,
1142 }
1143
1144 impl DropListFixture {
1145 fn new(accepted: &[&str]) -> Self {
1146 Self {
1147 items: Vec::new(),
1148 accepted: accepted.iter().map(|s| s.to_string()).collect(),
1149 entered: false,
1150 log: Vec::new(),
1151 }
1152 }
1153
1154 fn drain_log(&mut self) -> Vec<String> {
1155 std::mem::take(&mut self.log)
1156 }
1157 }
1158
1159 impl DropTarget for DropListFixture {
1160 fn can_accept(&self, drag_type: &str) -> bool {
1161 self.accepted.iter().any(|t| t == drag_type)
1162 }
1163
1164 fn drop_position(&self, pos: Position, _payload: &DragPayload) -> DropPosition {
1165 if self.items.is_empty() {
1166 DropPosition::Append
1167 } else {
1168 DropPosition::from_list(pos.y, 1, self.items.len())
1169 }
1170 }
1171
1172 fn on_drop(&mut self, payload: DragPayload, position: DropPosition) -> DropResult {
1173 if let Some(text) = payload.as_text() {
1174 let idx = match position {
1175 DropPosition::Before(i) => i,
1176 DropPosition::After(i) => i + 1,
1177 DropPosition::Append => self.items.len(),
1178 _ => return DropResult::rejected("unsupported position"),
1179 };
1180 self.items.insert(idx, text.to_string());
1181 self.log
1182 .push(format!("target:drop text={text} position={position:?}"));
1183 DropResult::Accepted
1184 } else {
1185 DropResult::rejected("expected text")
1186 }
1187 }
1188
1189 fn on_drag_enter(&mut self) {
1190 self.entered = true;
1191 self.log.push("target:enter".to_string());
1192 }
1193
1194 fn on_drag_leave(&mut self) {
1195 self.entered = false;
1196 self.log.push("target:leave".to_string());
1197 }
1198
1199 fn accepted_types(&self) -> &[&str] {
1200 &[]
1201 }
1202 }
1203
1204 #[test]
1205 fn drop_target_can_accept() {
1206 let target = DropListFixture::new(&["text/plain", "widget/item"]);
1207 assert!(target.can_accept("text/plain"));
1208 assert!(target.can_accept("widget/item"));
1209 assert!(!target.can_accept("image/png"));
1210 }
1211
1212 #[test]
1213 fn drop_target_drop_position_empty() {
1214 let target = DropListFixture::new(&["text/plain"]);
1215 let pos = target.drop_position(Position::new(0, 0), &DragPayload::text("x"));
1216 assert_eq!(pos, DropPosition::Append);
1217 }
1218
1219 #[test]
1220 fn drop_target_on_drop_accepted() {
1221 let mut target = DropListFixture::new(&["text/plain"]);
1222 let result = target.on_drop(DragPayload::text("hello"), DropPosition::Append);
1223 assert!(result.is_accepted());
1224 assert_eq!(target.items, vec!["hello"]);
1225 }
1226
1227 #[test]
1228 fn drop_target_on_drop_insert_before() {
1229 let mut target = DropListFixture::new(&["text/plain"]);
1230 target.items = vec!["a".into(), "b".into()];
1231 let result = target.on_drop(DragPayload::text("x"), DropPosition::Before(1));
1232 assert!(result.is_accepted());
1233 assert_eq!(target.items, vec!["a", "x", "b"]);
1234 }
1235
1236 #[test]
1237 fn drop_target_on_drop_insert_after() {
1238 let mut target = DropListFixture::new(&["text/plain"]);
1239 target.items = vec!["a".into(), "b".into()];
1240 let result = target.on_drop(DragPayload::text("x"), DropPosition::After(0));
1241 assert!(result.is_accepted());
1242 assert_eq!(target.items, vec!["a", "x", "b"]);
1243 }
1244
1245 #[test]
1246 fn drop_target_on_drop_rejected_non_text() {
1247 let mut target = DropListFixture::new(&["application/octet-stream"]);
1248 let payload = DragPayload::new("application/octet-stream", vec![0xFF, 0xFE]);
1249 let result = target.on_drop(payload, DropPosition::Append);
1250 assert!(!result.is_accepted());
1251 }
1252
1253 #[test]
1254 fn drop_target_enter_leave() {
1255 let mut target = DropListFixture::new(&[]);
1256 assert!(!target.entered);
1257 target.on_drag_enter();
1258 assert!(target.entered);
1259 target.on_drag_leave();
1260 assert!(!target.entered);
1261 }
1262
1263 #[test]
1266 fn preview_config_defaults() {
1267 let cfg = DragPreviewConfig::default();
1268 assert!((cfg.opacity - 0.7).abs() < f32::EPSILON);
1269 assert_eq!(cfg.offset_x, 1);
1270 assert_eq!(cfg.offset_y, 1);
1271 assert_eq!(cfg.width, 20);
1272 assert_eq!(cfg.height, 1);
1273 assert!(cfg.background.is_none());
1274 assert!(!cfg.show_border);
1275 }
1276
1277 #[test]
1278 fn preview_config_builder() {
1279 let cfg = DragPreviewConfig::default()
1280 .with_opacity(0.5)
1281 .with_offset(2, 3)
1282 .with_size(30, 5)
1283 .with_background(PackedRgba::rgb(40, 40, 40))
1284 .with_border();
1285 assert!((cfg.opacity - 0.5).abs() < f32::EPSILON);
1286 assert_eq!(cfg.offset_x, 2);
1287 assert_eq!(cfg.offset_y, 3);
1288 assert_eq!(cfg.width, 30);
1289 assert_eq!(cfg.height, 5);
1290 assert!(cfg.background.is_some());
1291 assert!(cfg.show_border);
1292 }
1293
1294 #[test]
1295 fn preview_config_opacity_clamped() {
1296 let cfg = DragPreviewConfig::default().with_opacity(2.0);
1297 assert!((cfg.opacity - 1.0).abs() < f32::EPSILON);
1298 let cfg = DragPreviewConfig::default().with_opacity(-0.5);
1299 assert!((cfg.opacity - 0.0).abs() < f32::EPSILON);
1300 }
1301
1302 #[test]
1303 fn preview_rect_basic() {
1304 let cfg = DragPreviewConfig::default().with_size(10, 3);
1305 let viewport = Rect::new(0, 0, 80, 24);
1306 let cursor = Position::new(10, 5);
1307 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1308 assert_eq!(rect.x, 11); assert_eq!(rect.y, 6); assert_eq!(rect.width, 10);
1311 assert_eq!(rect.height, 3);
1312 }
1313
1314 #[test]
1315 fn preview_rect_clamped_to_right_edge() {
1316 let cfg = DragPreviewConfig::default().with_size(10, 1);
1317 let viewport = Rect::new(0, 0, 80, 24);
1318 let cursor = Position::new(75, 5);
1319 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1320 assert!(rect.x + rect.width <= 80);
1322 }
1323
1324 #[test]
1325 fn preview_rect_clamped_to_bottom_edge() {
1326 let cfg = DragPreviewConfig::default().with_size(10, 3);
1327 let viewport = Rect::new(0, 0, 80, 24);
1328 let cursor = Position::new(5, 22);
1329 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1330 assert!(rect.y + rect.height <= 24);
1331 }
1332
1333 #[test]
1334 fn preview_rect_at_origin() {
1335 let cfg = DragPreviewConfig::default()
1336 .with_offset(0, 0)
1337 .with_size(5, 2);
1338 let viewport = Rect::new(0, 0, 80, 24);
1339 let cursor = Position::new(0, 0);
1340 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1341 assert_eq!(rect.x, 0);
1342 assert_eq!(rect.y, 0);
1343 }
1344
1345 #[test]
1346 fn preview_rect_viewport_offset() {
1347 let cfg = DragPreviewConfig::default()
1348 .with_offset(-5, -5)
1349 .with_size(10, 3);
1350 let viewport = Rect::new(10, 10, 60, 14);
1351 let cursor = Position::new(12, 12);
1352 let rect = cfg.preview_rect(cursor, viewport).unwrap();
1353 assert!(rect.x >= viewport.x);
1355 assert!(rect.y >= viewport.y);
1356 }
1357
1358 #[test]
1361 fn drag_preview_new() {
1362 let state = DragState::new(WidgetId(1), DragPayload::text("hello"), Position::new(5, 5));
1363 let preview = DragPreview::new(&state);
1364 assert!((preview.config.opacity - 0.7).abs() < f32::EPSILON);
1365 }
1366
1367 #[test]
1368 fn drag_preview_with_config() {
1369 let state = DragState::new(WidgetId(1), DragPayload::text("hello"), Position::new(5, 5));
1370 let cfg = DragPreviewConfig::default().with_opacity(0.5);
1371 let preview = DragPreview::with_config(&state, cfg);
1372 assert!((preview.config.opacity - 0.5).abs() < f32::EPSILON);
1373 }
1374
1375 #[test]
1376 fn drag_preview_is_not_essential() {
1377 let state = DragState::new(WidgetId(1), DragPayload::text("hello"), Position::new(5, 5));
1378 let preview = DragPreview::new(&state);
1379 assert!(!preview.is_essential());
1380 }
1381
1382 #[test]
1383 fn drag_preview_render_text_fallback() {
1384 use ftui_render::grapheme_pool::GraphemePool;
1385
1386 let state = DragState::new(
1387 WidgetId(1),
1388 DragPayload::text("dragged item"),
1389 Position::new(5, 5),
1390 );
1391 let preview =
1392 DragPreview::with_config(&state, DragPreviewConfig::default().with_size(20, 1));
1393
1394 let mut pool = GraphemePool::new();
1395 let mut frame = Frame::new(80, 24, &mut pool);
1396 let viewport = Rect::new(0, 0, 80, 24);
1397 preview.render(viewport, &mut frame);
1398
1399 let cell = frame.buffer.get(6, 6).unwrap();
1401 assert_eq!(cell.content.as_char(), Some('d')); }
1403
1404 #[test]
1405 fn drag_preview_render_with_border() {
1406 use ftui_render::grapheme_pool::GraphemePool;
1407
1408 let state = DragState::new(WidgetId(1), DragPayload::text("hi"), Position::new(5, 5));
1409 let preview = DragPreview::with_config(
1410 &state,
1411 DragPreviewConfig::default().with_size(10, 3).with_border(),
1412 );
1413
1414 let mut pool = GraphemePool::new();
1415 let mut frame = Frame::new(80, 24, &mut pool);
1416 let viewport = Rect::new(0, 0, 80, 24);
1417 preview.render(viewport, &mut frame);
1418
1419 let corner = frame.buffer.get(6, 6).unwrap();
1421 assert_eq!(corner.content.as_char(), Some('┌'));
1422 }
1423
1424 #[test]
1425 fn drag_preview_empty_area_noop() {
1426 use ftui_render::grapheme_pool::GraphemePool;
1427
1428 let state = DragState::new(WidgetId(1), DragPayload::text("hi"), Position::new(0, 0));
1429 let preview = DragPreview::new(&state);
1430
1431 let mut pool = GraphemePool::new();
1432 let mut frame = Frame::new(80, 24, &mut pool);
1433 preview.render(Rect::new(0, 0, 0, 0), &mut frame);
1435 }
1436
1437 fn run_drag_sequence(
1440 source: &mut DragSourceFixture,
1441 target: Option<&mut DropListFixture>,
1442 start: Position,
1443 moves: &[Position],
1444 ) -> (DragState, Option<DropResult>, Vec<String>) {
1445 let mut log = Vec::new();
1446 log.push(format!("event:start pos=({},{})", start.x, start.y));
1447
1448 source.on_drag_start();
1449 log.extend(source.drain_log());
1450
1451 let payload = source.drag_data();
1452 let mut state = DragState::new(WidgetId(99), payload, start);
1453
1454 for (idx, pos) in moves.iter().enumerate() {
1455 state.update_position(*pos);
1456 log.push(format!(
1457 "event:move#{idx} pos=({},{}) delta={:?}",
1458 pos.x,
1459 pos.y,
1460 state.delta()
1461 ));
1462 }
1463
1464 let drop_result = if let Some(target) = target {
1465 if target.can_accept(&state.payload.drag_type) {
1466 target.on_drag_enter();
1467 log.extend(target.drain_log());
1468 let pos = target.drop_position(state.current_pos, &state.payload);
1469 log.push(format!("event:drop_position={pos:?}"));
1470 let result = target.on_drop(state.payload.clone(), pos);
1471 log.extend(target.drain_log());
1472 target.on_drag_leave();
1473 log.extend(target.drain_log());
1474 source.on_drag_end(result.is_accepted());
1475 log.extend(source.drain_log());
1476 Some(result)
1477 } else {
1478 source.on_drag_end(false);
1479 log.extend(source.drain_log());
1480 None
1481 }
1482 } else {
1483 source.on_drag_end(false);
1484 log.extend(source.drain_log());
1485 None
1486 };
1487
1488 (state, drop_result, log)
1489 }
1490
1491 #[test]
1492 fn full_drag_lifecycle() {
1493 let mut source = DragSourceFixture::new("file.txt");
1494 let moves = [Position::new(10, 8), Position::new(20, 15)];
1495 let (state, result, log) =
1496 run_drag_sequence(&mut source, None, Position::new(5, 5), &moves);
1497
1498 assert!(result.is_none(), "unexpected drop result for no target");
1499 assert_eq!(state.distance(), 25, "distance mismatch after moves");
1500 assert_eq!(source.ended_with, Some(false));
1501 assert_eq!(
1502 state.payload.as_text(),
1503 Some("file.txt"),
1504 "payload text mismatch after drag"
1505 );
1506 assert_eq!(
1507 log,
1508 vec![
1509 "event:start pos=(5,5)".to_string(),
1510 "source:start label=file.txt".to_string(),
1511 "event:move#0 pos=(10,8) delta=(5, 3)".to_string(),
1512 "event:move#1 pos=(20,15) delta=(15, 10)".to_string(),
1513 "source:end label=file.txt success=false".to_string(),
1514 ],
1515 "drag log mismatch"
1516 );
1517 }
1518
1519 #[test]
1520 fn full_drag_and_drop_lifecycle() {
1521 let mut source = DragSourceFixture::new("item-A");
1522 let mut target = DropListFixture::new(&["text/plain"]);
1523 target.items = vec!["existing".into()];
1524
1525 let moves = [Position::new(10, 5)];
1526 let (_state, result, log) =
1527 run_drag_sequence(&mut source, Some(&mut target), Position::new(0, 0), &moves);
1528
1529 let result = match result {
1530 Some(result) => result,
1531 None => unreachable!("expected drop result from target"),
1532 };
1533
1534 assert!(result.is_accepted(), "drop result should be accepted");
1535 assert_eq!(source.ended_with, Some(true));
1536 assert!(!target.entered, "target should be left after drop");
1537 assert_eq!(target.items.len(), 2, "target item count mismatch");
1538 assert_eq!(
1539 log,
1540 vec![
1541 "event:start pos=(0,0)".to_string(),
1542 "source:start label=item-A".to_string(),
1543 "event:move#0 pos=(10,5) delta=(10, 5)".to_string(),
1544 "target:enter".to_string(),
1545 "event:drop_position=Append".to_string(),
1546 "target:drop text=item-A position=Append".to_string(),
1547 "target:leave".to_string(),
1548 "source:end label=item-A success=true".to_string(),
1549 ],
1550 "drag/drop log mismatch"
1551 );
1552 }
1553}