Skip to main content

ftui_widgets/
drag.rs

1#![forbid(unsafe_code)]
2
3//! Drag-and-drop protocol (bd-1csc.1 + bd-1csc.2).
4//!
5//! Defines the [`Draggable`] trait for drag sources and [`DropTarget`] trait
6//! for drop targets, along with [`DragPayload`] for transferring typed data,
7//! [`DragState`] for tracking active drags, [`DropPosition`] for specifying
8//! where within a target the drop occurs, and [`DropResult`] for communicating
9//! drop outcomes.
10//!
11//! # Design
12//!
13//! ## Integration with Semantic Events
14//!
15//! Drag detection is handled by the gesture recognizer in `ftui-core`, which
16//! emits `SemanticEvent::DragStart`, `DragMove`, `DragEnd`, and `DragCancel`.
17//! The drag manager (bd-1csc.3) listens for these events, identifies the
18//! source widget via hit-test, and calls the [`Draggable`] methods.
19//!
20//! ## Invariants
21//!
22//! 1. A drag operation is well-formed: exactly one `DragStart` followed by
23//!    zero or more `DragMove` events, ending in either `DragEnd` or
24//!    `DragCancel`.
25//! 2. `on_drag_start` is called exactly once per drag, before any `DragMove`.
26//! 3. `on_drag_end` is called exactly once per drag, with `success = true`
27//!    if dropped on a valid target, `false` otherwise.
28//! 4. `drag_type` must return a stable string for the lifetime of the drag.
29//!
30//! ## Failure Modes
31//!
32//! | Failure | Cause | Fallback |
33//! |---------|-------|----------|
34//! | No hit-test match at drag start | Click outside any draggable | Drag not initiated |
35//! | Payload decode failure | Type mismatch at drop target | Drop rejected |
36//! | Focus loss mid-drag | Window deactivation | `DragCancel` emitted |
37//! | Escape pressed mid-drag | User cancellation | `DragCancel` emitted (if `cancel_on_escape`) |
38
39use 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// ---------------------------------------------------------------------------
48// DragPayload
49// ---------------------------------------------------------------------------
50
51/// Data carried during a drag operation.
52///
53/// The payload uses a MIME-like type string for matching against drop targets
54/// and raw bytes for the actual data. This decouples the drag source from
55/// the drop target — they only need to agree on the type string and byte
56/// format.
57///
58/// # Examples
59///
60/// ```
61/// # use ftui_widgets::drag::DragPayload;
62/// let payload = DragPayload::text("hello world");
63/// assert_eq!(payload.drag_type, "text/plain");
64/// assert_eq!(payload.display_text.as_deref(), Some("hello world"));
65/// ```
66#[derive(Clone, Debug)]
67pub struct DragPayload {
68    /// MIME-like type identifier (e.g., `"text/plain"`, `"widget/list-item"`).
69    pub drag_type: String,
70    /// Raw serialized data.
71    pub data: Vec<u8>,
72    /// Human-readable preview text shown during drag (optional).
73    pub display_text: Option<String>,
74}
75
76impl DragPayload {
77    /// Create a payload with raw bytes.
78    #[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    /// Create a plain-text payload.
88    #[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    /// Create a payload with custom display text.
100    #[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    /// Attempt to decode the data as a UTF-8 string.
107    #[must_use]
108    pub fn as_text(&self) -> Option<&str> {
109        std::str::from_utf8(&self.data).ok()
110    }
111
112    /// Returns the byte length of the payload data.
113    #[must_use]
114    pub fn data_len(&self) -> usize {
115        self.data.len()
116    }
117
118    /// Returns true if the payload type matches the given pattern.
119    ///
120    /// Supports exact match and wildcard prefix (e.g., `"text/*"`).
121    #[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// ---------------------------------------------------------------------------
136// DragConfig
137// ---------------------------------------------------------------------------
138
139/// Configuration for drag gesture detection.
140///
141/// Controls how mouse movement is interpreted as a drag versus a click.
142#[derive(Clone, Debug)]
143pub struct DragConfig {
144    /// Minimum movement in cells before a drag starts (default: 3).
145    pub threshold_cells: u16,
146    /// Delay in milliseconds before drag starts (default: 0).
147    ///
148    /// A non-zero delay requires the user to hold the mouse button for
149    /// this long before movement triggers a drag.
150    pub start_delay_ms: u64,
151    /// Whether pressing Escape cancels an active drag (default: true).
152    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    /// Create a config with custom threshold.
167    #[must_use]
168    pub fn with_threshold(mut self, cells: u16) -> Self {
169        self.threshold_cells = cells;
170        self
171    }
172
173    /// Create a config with start delay.
174    #[must_use]
175    pub fn with_delay(mut self, ms: u64) -> Self {
176        self.start_delay_ms = ms;
177        self
178    }
179
180    /// Create a config where Escape does not cancel drags.
181    #[must_use]
182    pub fn no_escape_cancel(mut self) -> Self {
183        self.cancel_on_escape = false;
184        self
185    }
186}
187
188// ---------------------------------------------------------------------------
189// DragState
190// ---------------------------------------------------------------------------
191
192/// Active drag operation state.
193///
194/// Created when a drag starts and destroyed when it ends or is cancelled.
195/// The drag manager (bd-1csc.3) owns this state.
196pub struct DragState {
197    /// Widget that initiated the drag.
198    pub source_id: WidgetId,
199    /// Data being dragged.
200    pub payload: DragPayload,
201    /// Position where the drag started.
202    pub start_pos: Position,
203    /// Current drag position.
204    pub current_pos: Position,
205    /// Optional custom preview widget.
206    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    /// Create a new drag state.
223    #[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    /// Set a custom preview widget.
235    #[must_use]
236    pub fn with_preview(mut self, preview: Box<dyn Widget>) -> Self {
237        self.preview = Some(preview);
238        self
239    }
240
241    /// Update the current position during a drag move.
242    pub fn update_position(&mut self, pos: Position) {
243        self.current_pos = pos;
244    }
245
246    /// Manhattan distance from start to current position.
247    #[must_use]
248    pub fn distance(&self) -> u32 {
249        self.start_pos.manhattan_distance(self.current_pos)
250    }
251
252    /// Delta from start to current position as `(dx, dy)`.
253    #[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
262// ---------------------------------------------------------------------------
263// Draggable trait
264// ---------------------------------------------------------------------------
265
266/// Trait for widgets that can be drag sources.
267///
268/// Implement this trait to allow a widget to participate in drag-and-drop
269/// operations. The drag manager calls these methods during the drag lifecycle.
270///
271/// # Example
272///
273/// ```ignore
274/// use ftui_widgets::drag::{Draggable, DragPayload, DragConfig};
275///
276/// struct FileItem { path: String }
277///
278/// impl Draggable for FileItem {
279///     fn drag_type(&self) -> &str { "application/file-path" }
280///
281///     fn drag_data(&self) -> DragPayload {
282///         DragPayload::new("application/file-path", self.path.as_bytes().to_vec())
283///             .with_display_text(&self.path)
284///     }
285/// }
286/// ```
287pub trait Draggable {
288    /// MIME-like type identifier for the dragged data.
289    ///
290    /// Must return a stable string for the lifetime of the drag.
291    /// Examples: `"text/plain"`, `"widget/list-item"`, `"application/file-path"`.
292    fn drag_type(&self) -> &str;
293
294    /// Produce the drag payload.
295    ///
296    /// Called once when the drag starts to capture the data being transferred.
297    fn drag_data(&self) -> DragPayload;
298
299    /// Optional custom preview widget shown during the drag.
300    ///
301    /// Return `None` to use the default text-based preview from
302    /// `DragPayload::display_text`.
303    fn drag_preview(&self) -> Option<Box<dyn Widget>> {
304        None
305    }
306
307    /// Drag gesture configuration for this widget.
308    ///
309    /// Override to customize threshold, delay, or escape behaviour.
310    fn drag_config(&self) -> DragConfig {
311        DragConfig::default()
312    }
313
314    /// Called when a drag operation starts from this widget.
315    ///
316    /// Use this to apply visual feedback (e.g., dim the source item).
317    fn on_drag_start(&mut self) {}
318
319    /// Called when the drag operation ends.
320    ///
321    /// `success` is `true` if the payload was accepted by a drop target,
322    /// `false` if the drag was cancelled or dropped on an invalid target.
323    fn on_drag_end(&mut self, _success: bool) {}
324}
325
326// ---------------------------------------------------------------------------
327// DropPosition
328// ---------------------------------------------------------------------------
329
330/// Where within a drop target the drop will occur.
331///
332/// Used by [`DropTarget::drop_position`] to communicate precise placement
333/// to the drop handler.
334#[derive(Clone, Copy, Debug, PartialEq, Eq)]
335pub enum DropPosition {
336    /// Before the item at the given index.
337    Before(usize),
338    /// After the item at the given index.
339    After(usize),
340    /// Inside the item at the given index (for tree-like targets).
341    Inside(usize),
342    /// Replace the item at the given index.
343    Replace(usize),
344    /// Append to the end of the target's items.
345    Append,
346}
347
348impl DropPosition {
349    /// Returns the index associated with this position, if any.
350    #[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    /// Returns true if this is an insertion position (`Before` or `After`).
359    #[must_use]
360    pub fn is_insertion(&self) -> bool {
361        matches!(self, Self::Before(_) | Self::After(_) | Self::Append)
362    }
363
364    /// Calculate a list drop position from a y coordinate within a list.
365    ///
366    /// Divides each item's height in half: the upper half maps to `Before`,
367    /// the lower half maps to `After`.
368    ///
369    /// # Panics
370    ///
371    /// Panics if `item_height` is zero.
372    #[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// ---------------------------------------------------------------------------
392// DropResult
393// ---------------------------------------------------------------------------
394
395/// Outcome of a drop operation.
396#[derive(Clone, Debug, PartialEq, Eq)]
397pub enum DropResult {
398    /// Drop was accepted and applied.
399    Accepted,
400    /// Drop was rejected with a reason.
401    Rejected {
402        /// Human-readable explanation for why the drop was rejected.
403        reason: String,
404    },
405}
406
407impl DropResult {
408    /// Create a rejection with the given reason.
409    #[must_use]
410    pub fn rejected(reason: impl Into<String>) -> Self {
411        Self::Rejected {
412            reason: reason.into(),
413        }
414    }
415
416    /// Returns true if the drop was accepted.
417    #[must_use]
418    pub fn is_accepted(&self) -> bool {
419        matches!(self, Self::Accepted)
420    }
421}
422
423// ---------------------------------------------------------------------------
424// DropTarget trait
425// ---------------------------------------------------------------------------
426
427/// Trait for widgets that can accept drops.
428///
429/// Implement this trait to allow a widget to be a drop target. The drag
430/// manager queries these methods during hover and drop to determine
431/// acceptance and placement.
432///
433/// # Example
434///
435/// ```ignore
436/// use ftui_widgets::drag::{DropTarget, DragPayload, DropPosition, DropResult};
437///
438/// struct FileList { files: Vec<String> }
439///
440/// impl DropTarget for FileList {
441///     fn can_accept(&self, drag_type: &str) -> bool {
442///         drag_type == "application/file-path" || drag_type == "text/plain"
443///     }
444///
445///     fn drop_position(&self, pos: Position, _payload: &DragPayload) -> DropPosition {
446///         DropPosition::from_list(pos.y, 1, self.files.len())
447///     }
448///
449///     fn on_drop(&mut self, payload: DragPayload, position: DropPosition) -> DropResult {
450///         if let Some(text) = payload.as_text() {
451///             let idx = match position {
452///                 DropPosition::Before(i) => i,
453///                 DropPosition::After(i) => i + 1,
454///                 DropPosition::Append => self.files.len(),
455///                 _ => return DropResult::rejected("unsupported position"),
456///             };
457///             self.files.insert(idx, text.to_string());
458///             DropResult::Accepted
459///         } else {
460///             DropResult::rejected("expected text payload")
461///         }
462///     }
463/// }
464/// ```
465pub trait DropTarget {
466    /// Check if this target accepts the given drag type.
467    ///
468    /// Called during hover to provide visual feedback (valid vs. invalid
469    /// target). Must be a cheap check — called on every mouse move during
470    /// a drag.
471    fn can_accept(&self, drag_type: &str) -> bool;
472
473    /// Calculate the drop position within this widget.
474    ///
475    /// `pos` is the cursor position relative to the widget's area origin.
476    /// `payload` provides access to the drag data for type-aware positioning.
477    fn drop_position(&self, pos: Position, payload: &DragPayload) -> DropPosition;
478
479    /// Handle the actual drop.
480    ///
481    /// Called when the user releases the mouse button over a valid target.
482    /// Returns [`DropResult::Accepted`] if the drop was handled, or
483    /// [`DropResult::Rejected`] with a reason if it cannot be applied.
484    fn on_drop(&mut self, payload: DragPayload, position: DropPosition) -> DropResult;
485
486    /// Called when a drag enters this target's area.
487    ///
488    /// Use for hover-enter visual feedback.
489    fn on_drag_enter(&mut self) {}
490
491    /// Called when a drag leaves this target's area.
492    ///
493    /// Use to clear hover visual feedback.
494    fn on_drag_leave(&mut self) {}
495
496    /// Accepted drag types as a list of MIME-like patterns.
497    ///
498    /// Override to provide a static list for documentation or introspection.
499    /// Defaults to an empty slice (use `can_accept` for runtime checks).
500    fn accepted_types(&self) -> &[&str] {
501        &[]
502    }
503}
504
505// ---------------------------------------------------------------------------
506// DragPreviewConfig
507// ---------------------------------------------------------------------------
508
509/// Configuration for the drag preview overlay.
510///
511/// Controls visual properties of the widget shown at the cursor during a drag.
512#[derive(Clone, Debug)]
513pub struct DragPreviewConfig {
514    /// Opacity of the preview widget (0.0 = invisible, 1.0 = fully opaque).
515    /// Default: 0.7.
516    pub opacity: f32,
517    /// Horizontal offset from cursor position in cells. Default: 1.
518    pub offset_x: i16,
519    /// Vertical offset from cursor position in cells. Default: 1.
520    pub offset_y: i16,
521    /// Width of the preview area in cells. Default: 20.
522    pub width: u16,
523    /// Height of the preview area in cells. Default: 1.
524    pub height: u16,
525    /// Background color for the preview area.
526    pub background: Option<PackedRgba>,
527    /// Whether to render a border around the preview. Default: false.
528    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    /// Set opacity (clamped to 0.0..=1.0).
547    #[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    /// Set cursor offset.
554    #[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    /// Set preview dimensions.
562    #[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    /// Set background color.
570    #[must_use]
571    pub fn with_background(mut self, color: PackedRgba) -> Self {
572        self.background = Some(color);
573        self
574    }
575
576    /// Enable border rendering.
577    #[must_use]
578    pub fn with_border(mut self) -> Self {
579        self.show_border = true;
580        self
581    }
582
583    /// Calculate the preview area given cursor position and viewport bounds.
584    ///
585    /// Clamps the preview rectangle to stay within the viewport. Returns
586    /// `None` if the preview would be fully outside the viewport.
587    #[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        // Clamp to viewport
593        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        // Ensure the rect is within viewport
608        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
623// ---------------------------------------------------------------------------
624// DragPreview
625// ---------------------------------------------------------------------------
626
627/// Overlay widget that renders a drag preview at the cursor position.
628///
629/// The preview renders either a custom widget (from [`DragState::preview`])
630/// or a text-based fallback from [`DragPayload::display_text`].
631///
632/// # Rendering
633///
634/// 1. Pushes the configured opacity onto the buffer's opacity stack.
635/// 2. Optionally fills the background.
636/// 3. Renders the custom preview widget or fallback text.
637/// 4. Optionally draws a border.
638/// 5. Pops the opacity.
639///
640/// # Invariants
641///
642/// - The preview is always clamped to the viewport bounds.
643/// - Opacity is always restored (pop matches push) even if the area is empty.
644/// - At `EssentialOnly` degradation or below, the preview is not rendered
645///   (it is decorative).
646pub struct DragPreview<'a> {
647    /// Current drag state (position, payload, optional custom preview).
648    pub drag_state: &'a DragState,
649    /// Visual configuration.
650    pub config: DragPreviewConfig,
651}
652
653impl<'a> DragPreview<'a> {
654    /// Create a new drag preview for the given state.
655    #[must_use]
656    pub fn new(drag_state: &'a DragState) -> Self {
657        Self {
658            drag_state,
659            config: DragPreviewConfig::default(),
660        }
661    }
662
663    /// Create a drag preview with custom configuration.
664    #[must_use]
665    pub fn with_config(drag_state: &'a DragState, config: DragPreviewConfig) -> Self {
666        Self { drag_state, config }
667    }
668
669    /// Render the fallback text preview.
670    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        // Truncate to available width
680        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    /// Render a simple border around the preview area.
694    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        // Corners
703        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        // Horizontal edges
717        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        // Vertical edges
727        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        // Skip decorative preview at degraded rendering levels
745        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        // Push opacity for ghost effect
754        frame.buffer.push_opacity(self.config.opacity);
755
756        // Fill background if configured
757        if let Some(bg) = self.config.background {
758            crate::set_style_area(&mut frame.buffer, preview_rect, Style::new().bg(bg));
759        }
760
761        // Render border if enabled (needs to happen before content so content
762        // can render inside the border)
763        if self.config.show_border {
764            self.render_border(preview_rect, frame);
765        }
766
767        // Content area (inset by border if present)
768        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        // Render custom preview or text fallback
781        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        // Restore opacity
788        frame.buffer.pop_opacity();
789    }
790
791    fn is_essential(&self) -> bool {
792        false // Drag preview is decorative
793    }
794}
795
796// ---------------------------------------------------------------------------
797// Tests
798// ---------------------------------------------------------------------------
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    // === DragPayload tests ===
805
806    #[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        // 0xFF is never valid in UTF-8
817        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()); // not valid UTF-8
821    }
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        // "text/*" should NOT match "textual/data" — prefix must end at slash
849        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    // === DragConfig tests ===
869
870    #[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    // === DragState tests ===
890
891    #[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); // manhattan: |3| + |4|
916    }
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    // === Draggable trait tests (via fixtures) ===
941
942    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    // === DropPosition tests ===
1047
1048    #[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        // y=0, item_height=4, item_count=3 → within_item=0 < 2 → Before(0)
1074        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        // y=2, item_height=4 → within_item=2 >= 2 → After(0)
1081        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        // y=5, item_height=4 → item_index=1, within_item=1 < 2 → Before(1)
1088        assert_eq!(DropPosition::from_list(4, 4, 3), DropPosition::Before(1));
1089        // y=6, item_height=4 → item_index=1, within_item=2 >= 2 → After(1)
1090        assert_eq!(DropPosition::from_list(6, 4, 3), DropPosition::After(1));
1091    }
1092
1093    #[test]
1094    fn drop_position_from_list_beyond_items() {
1095        // y=20, item_height=4, item_count=3 → item_index=5 >= 3 → Append
1096        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    // === DropResult tests ===
1106
1107    #[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    // === DropTarget trait tests (via fixtures) ===
1136
1137    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    // === DragPreviewConfig tests ===
1264
1265    #[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); // cursor.x + offset_x
1309        assert_eq!(rect.y, 6); // cursor.y + offset_y
1310        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        // Should be clamped so it doesn't extend past viewport
1321        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        // Should clamp to viewport origin
1354        assert!(rect.x >= viewport.x);
1355        assert!(rect.y >= viewport.y);
1356    }
1357
1358    // === DragPreview widget tests ===
1359
1360    #[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        // Text should appear at cursor + offset = (6, 6)
1400        let cell = frame.buffer.get(6, 6).unwrap();
1401        assert_eq!(cell.content.as_char(), Some('d')); // first char of "dragged item"
1402    }
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        // Top-left corner should be '┌' at (6, 6)
1420        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        // Empty area should not panic
1434        preview.render(Rect::new(0, 0, 0, 0), &mut frame);
1435    }
1436
1437    // === Integration: DragState with Draggable ===
1438
1439    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}