1use frus_core::{Color, Rect, Scene};
25use frus_layout::Style;
26
27use crate::interaction::{Status, WidgetId};
28use crate::theme::Theme;
29use crate::widget::{sizing_of, Widget};
30
31const GHOSTED_OPACITY: f32 = 0.35;
33const TARGET_ALPHA: f32 = 0.12;
35const TARGET_OUTLINE: f32 = 2.0;
37
38#[derive(Copy, Clone, Debug, PartialEq)]
40pub struct DragSource {
41 pub id: WidgetId,
43 pub rect: Rect,
45 pub payload: u64,
47}
48
49#[derive(Copy, Clone, Debug, PartialEq)]
51pub struct DropZone {
52 pub id: WidgetId,
54 pub rect: Rect,
56}
57
58pub struct Draggable<Msg = crate::callback::Callback> {
65 payload: u64,
66 long_press: bool,
67 ghost_opacity: f32,
68 on_dropped: Option<Box<dyn Fn(bool) -> Msg>>,
69 children: Vec<Box<dyn Widget<Msg>>>,
70}
71
72impl<Msg> Draggable<Msg> {
73 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
76 Self {
77 payload: 0,
78 long_press: false,
79 ghost_opacity: GHOSTED_OPACITY,
80 on_dropped: None,
81 children: vec![Box::new(child)],
82 }
83 }
84
85 pub fn payload(mut self, payload: u64) -> Self {
88 self.payload = payload;
89 self
90 }
91
92 pub fn long_press(mut self) -> Self {
100 self.long_press = true;
101 self
102 }
103
104 pub fn ghost_opacity(mut self, opacity: f32) -> Self {
107 self.ghost_opacity = opacity.clamp(0.0, 1.0);
108 self
109 }
110
111 pub fn on_dropped<R: crate::callback::IntoMsg<Msg>>(
116 mut self,
117 message: impl Fn(bool) -> R + 'static,
118 ) -> Self {
119 self.on_dropped = Some(Box::new(crate::callback::handler1(message)));
120 self
121 }
122}
123
124impl<Msg: Clone> Widget<Msg> for Draggable<Msg> {
125 fn style(&self) -> Style {
126 sizing_of(
127 self.children
128 .first()
129 .map(|child| child.style())
130 .unwrap_or_default(),
131 )
132 }
133
134 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
135 &self.children
136 }
137
138 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
139 }
141
142 fn on_click(&self) -> Option<Msg> {
143 None
144 }
145
146 fn stack(&self) -> bool {
154 self.children.first().is_some_and(|child| child.stack())
155 }
156
157 fn continuous(&self) -> bool {
158 self.children
159 .first()
160 .is_some_and(|child| child.continuous())
161 }
162
163 fn drag_payload(&self) -> Option<u64> {
164 Some(self.payload)
165 }
166
167 fn drag_needs_long_press(&self) -> bool {
168 self.long_press
169 }
170
171 fn drag_ghost_opacity(&self) -> f32 {
172 self.ghost_opacity
173 }
174
175 fn on_dropped(&self, accepted: bool) -> Option<Msg> {
176 self.on_dropped.as_ref().map(|make| make(accepted))
177 }
178}
179
180pub struct DragTarget<Msg = crate::callback::Callback> {
187 accepts: Option<Box<dyn Fn(u64) -> bool>>,
188 on_drop: Option<Box<dyn Fn(u64) -> Msg>>,
189 highlight: Option<Color>,
190 children: Vec<Box<dyn Widget<Msg>>>,
191}
192
193impl<Msg> DragTarget<Msg> {
194 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
196 Self {
197 accepts: None,
198 on_drop: None,
199 highlight: None,
200 children: vec![Box::new(child)],
201 }
202 }
203
204 pub fn on_drop<R: crate::callback::IntoMsg<Msg>>(
206 mut self,
207 message: impl Fn(u64) -> R + 'static,
208 ) -> Self {
209 self.on_drop = Some(Box::new(crate::callback::handler1(message)));
210 self
211 }
212
213 pub fn accepts(mut self, predicate: impl Fn(u64) -> bool + 'static) -> Self {
215 self.accepts = Some(Box::new(predicate));
216 self
217 }
218
219 pub fn highlight(mut self, color: Color) -> Self {
223 self.highlight = Some(color);
224 self
225 }
226}
227
228impl<Msg: Clone> Widget<Msg> for DragTarget<Msg> {
229 fn style(&self) -> Style {
230 sizing_of(
231 self.children
232 .first()
233 .map(|child| child.style())
234 .unwrap_or_default(),
235 )
236 }
237
238 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
239 &self.children
240 }
241
242 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
243 if !status.drag_over {
246 return;
247 }
248 let color = self.highlight.unwrap_or(theme.primary);
249 if color.a <= 0.0 {
250 return;
251 }
252 scene.draw_rect(
253 bounds,
254 color.fade(TARGET_ALPHA),
255 theme.radius,
256 TARGET_OUTLINE,
257 color,
258 );
259 }
260
261 fn on_click(&self) -> Option<Msg> {
262 None
263 }
264
265 fn stack(&self) -> bool {
273 self.children.first().is_some_and(|child| child.stack())
274 }
275
276 fn continuous(&self) -> bool {
277 self.children
278 .first()
279 .is_some_and(|child| child.continuous())
280 }
281
282 fn drop_zone(&self) -> bool {
283 true
284 }
285
286 fn accepts_drag(&self, payload: u64) -> bool {
287 match &self.accepts {
288 Some(predicate) => predicate(payload),
289 None => true,
290 }
291 }
292
293 fn on_drop(&self, payload: u64) -> Option<Msg> {
294 self.on_drop.as_ref().map(|make| make(payload))
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::{build_ui, Container, Flex, Runtime};
302 use frus_core::{Point, Primitive, Size};
303
304 #[derive(Clone, Debug, PartialEq)]
305 enum Msg {
306 Dropped(u64),
307 Ended(bool),
308 }
309
310 fn tree() -> Flex<Msg> {
311 Flex::column()
312 .width(200.0)
313 .child(
314 Draggable::new(Container::new().width(100.0).height(40.0))
315 .payload(7)
316 .on_dropped(Msg::Ended),
317 )
318 .child(
319 DragTarget::new(Container::new().width(200.0).height(60.0))
320 .on_drop(Msg::Dropped)
321 .accepts(|payload| payload % 2 == 1),
322 )
323 }
324
325 fn ui(runtime: &Runtime) -> crate::Ui<Msg> {
326 build_ui(&tree(), Size::new(200.0, 200.0), runtime, &Theme::dark())
327 }
328
329 #[test]
330 fn a_draggable_registers_what_it_carries_and_where_it_is() {
331 let runtime = Runtime::default();
332 let ui = ui(&runtime);
333 let source = ui
334 .drag_source_at(Point::new(50.0, 20.0))
335 .expect("a source under the pointer");
336 assert_eq!(source.payload, 7);
337 assert!((source.rect.height - 40.0).abs() < 0.5);
338 assert!(ui.drag_source_at(Point::new(50.0, 70.0)).is_none());
340 }
341
342 #[test]
343 fn a_draggable_says_how_it_wants_to_be_lifted() {
344 let plain = Draggable::<Msg>::new(Container::new());
345 assert!(!Widget::<Msg>::drag_needs_long_press(&plain));
346 let held = Draggable::<Msg>::new(Container::new()).long_press();
347 assert!(Widget::<Msg>::drag_needs_long_press(&held));
348 }
349
350 #[test]
351 fn a_draggable_row_inside_a_column_still_paints() {
352 use crate::{keyed, Dismissible, Flex};
353 let red = Color::rgb(1.0, 0.0, 0.0);
354 let row = Dismissible::<Msg>::new(Container::new().color(red).height(62.0))
355 .height(62.0)
356 .on_dismiss(Msg::Dropped(1));
357 let root = Flex::<Msg>::column()
358 .width(300.0)
359 .child(keyed(1u64, Draggable::new(row).payload(1).long_press()));
360 let runtime = Runtime::default();
361 let ui = build_ui(&root, Size::new(300.0, 400.0), &runtime, &Theme::dark());
362 let painted: Vec<Rect> = ui
363 .scene()
364 .primitives()
365 .iter()
366 .filter_map(|p| match p {
367 Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
368 Some(*rect)
369 }
370 _ => None,
371 })
372 .collect();
373 assert!(
374 !painted.is_empty(),
375 "the row vanished ({} primitives in all)",
376 ui.scene().primitives().len()
377 );
378 assert!((painted[0].height - 62.0).abs() < 1.0, "{:?}", painted[0]);
379 }
380
381 #[test]
384 fn a_target_around_ordinary_content_is_as_tall_as_that_content() {
385 use crate::Flex;
386 let red = Color::rgb(1.0, 0.0, 0.0);
387 let root = Flex::<Msg>::column().width(300.0).child(DragTarget::new(
388 Container::new()
389 .padding(12.0)
390 .color(red)
391 .child(Container::new().height(20.0)),
392 ));
393 let runtime = Runtime::default();
394 let ui = build_ui(&root, Size::new(300.0, 400.0), &runtime, &Theme::dark());
395 let painted = ui
396 .scene()
397 .primitives()
398 .iter()
399 .find_map(|p| match p {
400 Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
401 Some(*rect)
402 }
403 _ => None,
404 })
405 .expect("the target's content");
406 assert!(
407 (painted.height - 44.0).abs() < 1.0,
408 "20 px of content plus 12 either side: {painted:?}"
409 );
410 }
411
412 #[test]
413 fn a_target_is_found_by_where_it_is() {
414 let runtime = Runtime::default();
415 let ui = ui(&runtime);
416 assert!(ui.drop_zone_at(Point::new(100.0, 60.0)).is_some());
417 assert!(ui.drop_zone_at(Point::new(100.0, 10.0)).is_none());
418 }
419
420 #[test]
421 fn a_target_refuses_what_it_says_it_refuses() {
422 let target = DragTarget::<Msg>::new(Container::new()).accepts(|payload| payload % 2 == 1);
423 assert!(Widget::<Msg>::accepts_drag(&target, 7));
424 assert!(!Widget::<Msg>::accepts_drag(&target, 8));
425 let open = DragTarget::<Msg>::new(Container::new());
427 assert!(Widget::<Msg>::accepts_drag(&open, 8));
428 }
429
430 #[test]
431 fn the_payload_reaches_the_drop_untouched() {
432 let target = DragTarget::new(Container::new()).on_drop(Msg::Dropped);
434 assert_eq!(Widget::<Msg>::on_drop(&target, 7), Some(Msg::Dropped(7)));
435 let silent = DragTarget::<Msg>::new(Container::new());
437 assert_eq!(Widget::<Msg>::on_drop(&silent, 7), None);
438 }
439
440 #[test]
441 fn a_refused_drop_is_reported_as_such() {
442 let source = Draggable::new(Container::new()).on_dropped(Msg::Ended);
443 assert_eq!(
444 Widget::<Msg>::on_dropped(&source, true),
445 Some(Msg::Ended(true))
446 );
447 assert_eq!(
448 Widget::<Msg>::on_dropped(&source, false),
449 Some(Msg::Ended(false))
450 );
451 }
452
453 #[test]
454 fn a_target_is_only_highlighted_while_a_drag_is_over_it() {
455 let quiet = Runtime::default();
456 let plain = ui(&quiet).scene().primitives().len();
457
458 let mut hovered = Runtime::default();
459 let zone = ui(&quiet)
460 .drop_zone_at(Point::new(100.0, 60.0))
461 .expect("the target");
462 hovered.drag_over = Some(zone.id);
463 let lit = ui(&hovered);
464 assert_eq!(
465 lit.scene().primitives().len(),
466 plain + 1,
467 "exactly one highlight, and only when hovered"
468 );
469 let highlight = lit
471 .scene()
472 .primitives()
473 .iter()
474 .filter_map(|p| match p {
475 Primitive::Rect { rect, .. } if (rect.height - 60.0).abs() < 0.5 => Some(*rect),
476 _ => None,
477 })
478 .next()
479 .expect("the highlight");
480 assert!((highlight.width - 200.0).abs() < 0.5, "{highlight:?}");
481 }
482}