use crate::compat::String;
use crate::core::Point;
#[derive(Debug, Clone, PartialEq)]
pub struct DragPayload {
pub type_id: String,
pub item_id: String,
pub label: String,
pub origin: Point,
}
impl DragPayload {
pub fn new(type_id: impl Into<String>, item_id: impl Into<String>) -> Self {
Self {
type_id: type_id.into(),
item_id: item_id.into(),
label: String::new(),
origin: Point::new(0, 0),
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_origin(mut self, origin: Point) -> Self {
self.origin = origin;
self
}
pub fn is_type(&self, type_id: &str) -> bool {
self.type_id == type_id
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DropEffect {
#[default]
None,
Copy,
Move,
Link,
}
impl DropEffect {
pub fn consumes_source(self) -> bool {
matches!(self, DropEffect::Move)
}
pub fn is_accepted(self) -> bool {
!matches!(self, DropEffect::None)
}
}
pub trait DropTarget {
fn can_accept(&self, payload: &DragPayload) -> bool;
fn on_drop(&mut self, payload: &DragPayload, pos: Point) -> DropEffect;
fn preview_rect(&self, payload: &DragPayload, pos: Point) -> Option<crate::core::Rect> {
let _ = (payload, pos);
None
}
}
#[derive(Debug, Clone)]
pub struct DragSession {
payload: DragPayload,
start: Point,
current: Point,
active: bool,
}
impl DragSession {
pub fn begin(payload: DragPayload, start: Point) -> Self {
Self { payload, start, current: start, active: false }
}
pub fn payload(&self) -> &DragPayload {
&self.payload
}
pub fn start(&self) -> Point {
self.start
}
pub fn current(&self) -> Point {
self.current
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn delta(&self) -> Point {
Point::new(self.current.x - self.start.x, self.current.y - self.start.y)
}
pub fn update(&mut self, pos: Point, threshold: i32) -> bool {
self.current = pos;
if self.active {
return false;
}
let dx = (pos.x - self.start.x).abs();
let dy = (pos.y - self.start.y).abs();
if dx >= threshold || dy >= threshold {
self.active = true;
return true;
}
false
}
pub fn target_accepts(&self, target: &dyn DropTarget) -> bool {
self.active && target.can_accept(&self.payload)
}
pub fn drop_on(&mut self, target: &mut dyn DropTarget, pos: Point) -> DropEffect {
if !self.active {
return DropEffect::None;
}
if !target.can_accept(&self.payload) {
return DropEffect::None;
}
target.on_drop(&self.payload, pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::{MiniToString, String, Vec};
use crate::core::Rect;
#[derive(Default)]
struct Recorder {
dropped: Vec<String>,
effect: DropEffect,
preview: Option<Rect>,
}
impl DropTarget for Recorder {
fn can_accept(&self, payload: &DragPayload) -> bool {
payload.is_type("card")
}
fn on_drop(&mut self, payload: &DragPayload, _pos: Point) -> DropEffect {
self.dropped.push(payload.item_id.clone());
self.effect
}
fn preview_rect(&self, _payload: &DragPayload, _pos: Point) -> Option<Rect> {
self.preview
}
}
#[derive(Default)]
struct Refuser {
drop_calls: usize,
}
impl DropTarget for Refuser {
fn can_accept(&self, _payload: &DragPayload) -> bool {
false
}
fn on_drop(&mut self, _payload: &DragPayload, _pos: Point) -> DropEffect {
self.drop_calls += 1;
DropEffect::Move
}
}
fn card() -> DragPayload {
DragPayload::new("card", "task-1").with_label("Fix the parser")
}
#[test]
fn payload_type_filter_matches_only_its_own_id() {
let payload = card();
assert!(payload.is_type("card"));
assert!(!payload.is_type("tab"));
assert_eq!(payload.label, "Fix the parser");
}
#[test]
fn payload_builder_sets_label_and_origin() {
let payload = DragPayload::new("file", "/tmp/a").with_origin(Point::new(4, 5));
assert_eq!(payload.origin, Point::new(4, 5));
assert_eq!(payload.label, "");
}
#[test]
fn a_press_below_threshold_is_not_yet_a_drag() {
let mut session = DragSession::begin(card(), Point::new(10, 10));
assert!(!session.is_active());
assert!(!session.update(Point::new(14, 12), 8));
assert!(!session.is_active());
}
#[test]
fn a_move_past_threshold_activates_the_drag_exactly_once() {
let mut session = DragSession::begin(card(), Point::new(10, 10));
assert!(session.update(Point::new(30, 10), 8), "the first crossing reports the change");
assert!(session.is_active());
assert!(!session.update(Point::new(60, 10), 8));
assert!(session.is_active());
}
#[test]
fn a_threshold_refuser_is_never_asked_to_drop() {
let mut session = DragSession::begin(card(), Point::new(0, 0));
session.update(Point::new(50, 0), 8);
let mut target = Refuser::default();
assert!(!session.target_accepts(&target));
assert_eq!(session.drop_on(&mut target, Point::new(50, 0)), DropEffect::None);
assert_eq!(target.drop_calls, 0, "a refused payload must not reach on_drop");
}
#[test]
fn an_inactive_session_never_drops() {
let mut session = DragSession::begin(card(), Point::new(0, 0));
let mut target = Recorder { effect: DropEffect::Move, ..Recorder::default() };
assert_eq!(session.drop_on(&mut target, Point::new(0, 0)), DropEffect::None);
assert!(target.dropped.is_empty());
}
#[test]
fn an_accepted_payload_reaches_the_commit() {
let mut session = DragSession::begin(card(), Point::new(0, 0));
session.update(Point::new(40, 0), 8);
let mut target = Recorder { effect: DropEffect::Move, ..Recorder::default() };
assert!(session.target_accepts(&target));
assert_eq!(session.drop_on(&mut target, Point::new(40, 0)), DropEffect::Move);
assert_eq!(target.dropped, vec!["task-1".to_string()]);
}
#[test]
fn drop_effect_describes_what_the_source_should_do() {
assert!(DropEffect::Move.consumes_source());
assert!(!DropEffect::Copy.consumes_source());
assert!(!DropEffect::Link.consumes_source());
assert!(!DropEffect::None.consumes_source());
assert!(DropEffect::Copy.is_accepted());
assert!(DropEffect::Move.is_accepted());
assert!(DropEffect::Link.is_accepted());
assert!(!DropEffect::None.is_accepted());
}
#[test]
fn default_effect_is_none_so_a_forgotten_field_restores() {
assert_eq!(DropEffect::default(), DropEffect::None);
assert!(!DropEffect::default().consumes_source());
}
#[test]
fn delta_tracks_the_pointer_offset() {
let mut session = DragSession::begin(card(), Point::new(10, 20));
session.update(Point::new(35, 5), 8);
assert_eq!(session.delta(), Point::new(25, -15));
assert_eq!(session.start(), Point::new(10, 20));
assert_eq!(session.current(), Point::new(35, 5));
}
#[test]
fn preview_rect_defaults_to_none() {
struct NoPreview;
impl DropTarget for NoPreview {
fn can_accept(&self, _payload: &DragPayload) -> bool {
true
}
fn on_drop(&mut self, _payload: &DragPayload, _pos: Point) -> DropEffect {
DropEffect::Copy
}
}
let target = NoPreview;
assert_eq!(target.preview_rect(&card(), Point::new(1, 1)), None);
}
#[test]
fn preview_rect_is_reported_when_a_target_offers_one() {
let target = Recorder { preview: Some(Rect::new(0, 10, 100, 4)), ..Recorder::default() };
assert_eq!(target.preview_rect(&card(), Point::new(5, 5)), Some(Rect::new(0, 10, 100, 4)));
}
#[test]
fn a_target_may_refuse_at_commit_time() {
let mut session = DragSession::begin(card(), Point::new(0, 0));
session.update(Point::new(40, 0), 8);
let mut target = Recorder { effect: DropEffect::None, ..Recorder::default() };
assert_eq!(session.drop_on(&mut target, Point::new(40, 0)), DropEffect::None);
assert_eq!(target.dropped.len(), 1, "the target still saw the attempt");
}
#[test]
fn vertical_travel_alone_activates_the_drag() {
let mut session = DragSession::begin(card(), Point::new(0, 0));
assert!(session.update(Point::new(2, 40), 8));
assert!(session.is_active());
}
}