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        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    /// Render a simple border around the preview area.
690    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        // Corners
699        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        // Horizontal edges
713        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        // Vertical edges
723        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        // Skip decorative preview at degraded rendering levels
741        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        // Push opacity for ghost effect
750        frame.buffer.push_opacity(self.config.opacity);
751
752        // Fill background if configured
753        if let Some(bg) = self.config.background {
754            crate::set_style_area(&mut frame.buffer, preview_rect, Style::new().bg(bg));
755        }
756
757        // Render border if enabled (needs to happen before content so content
758        // can render inside the border)
759        if self.config.show_border {
760            self.render_border(preview_rect, frame);
761        }
762
763        // Content area (inset by border if present)
764        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        // Render custom preview or text fallback
777        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        // Restore opacity
784        frame.buffer.pop_opacity();
785    }
786
787    fn is_essential(&self) -> bool {
788        false // Drag preview is decorative
789    }
790}
791
792// ---------------------------------------------------------------------------
793// Tests
794// ---------------------------------------------------------------------------
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    // === DragPayload tests ===
801
802    #[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        // 0xFF is never valid in UTF-8
813        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()); // not valid UTF-8
817    }
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        // "text/*" should NOT match "textual/data" — prefix must end at slash
845        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    // === DragConfig tests ===
865
866    #[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    // === DragState tests ===
886
887    #[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); // manhattan: |3| + |4|
912    }
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    // === Draggable trait tests (via fixtures) ===
937
938    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    // === DropPosition tests ===
1043
1044    #[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        // y=0, item_height=4, item_count=3 → within_item=0 < 2 → Before(0)
1070        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        // y=2, item_height=4 → within_item=2 >= 2 → After(0)
1077        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        // y=5, item_height=4 → item_index=1, within_item=1 < 2 → Before(1)
1084        assert_eq!(DropPosition::from_list(4, 4, 3), DropPosition::Before(1));
1085        // y=6, item_height=4 → item_index=1, within_item=2 >= 2 → After(1)
1086        assert_eq!(DropPosition::from_list(6, 4, 3), DropPosition::After(1));
1087    }
1088
1089    #[test]
1090    fn drop_position_from_list_beyond_items() {
1091        // y=20, item_height=4, item_count=3 → item_index=5 >= 3 → Append
1092        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    // === DropResult tests ===
1102
1103    #[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    // === DropTarget trait tests (via fixtures) ===
1132
1133    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    // === DragPreviewConfig tests ===
1260
1261    #[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); // cursor.x + offset_x
1305        assert_eq!(rect.y, 6); // cursor.y + offset_y
1306        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        // Should be clamped so it doesn't extend past viewport
1317        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        // Should clamp to viewport origin
1350        assert!(rect.x >= viewport.x);
1351        assert!(rect.y >= viewport.y);
1352    }
1353
1354    // === DragPreview widget tests ===
1355
1356    #[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        // Text should appear at cursor + offset = (6, 6)
1396        let cell = frame.buffer.get(6, 6).unwrap();
1397        assert_eq!(cell.content.as_char(), Some('d')); // first char of "dragged item"
1398    }
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        // Top-left corner should be '┌' at (6, 6)
1416        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        // Empty area should not panic
1430        preview.render(Rect::new(0, 0, 0, 0), &mut frame);
1431    }
1432
1433    // === Integration: DragState with Draggable ===
1434
1435    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}