1use azul_core::dom::{DomNodeId, OptionDomNodeId};
17use azul_core::drag::{ActiveDragType, DragContext};
18use azul_css::{impl_option, impl_option_inner, OptionString};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[repr(C)]
23pub enum DragType {
24 Node,
26 File,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32#[repr(C)]
33pub struct DragState {
34 pub drag_type: DragType,
36 pub source_node: OptionDomNodeId,
38 pub current_drop_target: OptionDomNodeId,
40 pub file_path: OptionString,
42}
43
44impl DragState {
45 #[must_use] pub fn from_context(ctx: &DragContext) -> Option<Self> {
47 match &ctx.drag_type {
48 ActiveDragType::Node(node_drag) => Some(Self {
49 drag_type: DragType::Node,
50 source_node: OptionDomNodeId::Some(DomNodeId {
51 dom: node_drag.dom_id,
52 node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(node_drag.node_id)),
53 }),
54 current_drop_target: node_drag.current_drop_target,
55 file_path: OptionString::None,
56 }),
57 ActiveDragType::FileDrop(file_drop) => Some(Self {
58 drag_type: DragType::File,
59 source_node: OptionDomNodeId::None,
60 current_drop_target: file_drop.drop_target,
61 file_path: file_drop.files.as_ref().first().cloned().into(),
62 }),
63 _ => None, }
65 }
66}
67
68impl_option!(
69 DragState,
70 OptionDragState,
71 copy = false,
72 [Debug, Clone, PartialEq, Eq]
73);
74
75#[cfg(test)]
76mod autotest_generated {
77 use azul_core::{
78 dom::{DomId, NodeId},
79 drag::{
80 ActiveDragType, DragData, DragEffect, DropEffect, FileDropDrag, NodeDrag,
81 ScrollbarAxis, WindowResizeDrag, WindowResizeEdge,
82 },
83 geom::LogicalPosition,
84 styled_dom::NodeHierarchyItemId,
85 window::WindowPosition,
86 };
87 use azul_css::AzString;
88
89 use super::*;
90
91 fn s(text: &str) -> AzString {
96 AzString::from(String::from(text))
97 }
98
99 fn node_ctx(dom: usize, node: usize) -> DragContext {
100 DragContext::node_drag(
101 DomId { inner: dom },
102 NodeId::new(node),
103 LogicalPosition::new(1.0, 2.0),
104 DragData::new(),
105 7,
106 )
107 }
108
109 fn node_drag_of(ctx: &mut DragContext) -> &mut NodeDrag {
111 match &mut ctx.drag_type {
112 ActiveDragType::Node(n) => n,
113 _ => unreachable!("node_ctx always builds ActiveDragType::Node"),
114 }
115 }
116
117 fn file_ctx(files: &[&str]) -> DragContext {
118 DragContext::file_drop(
119 files.iter().copied().map(s).collect(),
120 LogicalPosition::new(3.0, 4.0),
121 1,
122 )
123 }
124
125 fn file_drop_of(ctx: &mut DragContext) -> &mut FileDropDrag {
126 match &mut ctx.drag_type {
127 ActiveDragType::FileDrop(f) => f,
128 _ => unreachable!("file_ctx always builds ActiveDragType::FileDrop"),
129 }
130 }
131
132 fn dom_node(dom: usize, node: usize) -> DomNodeId {
133 DomNodeId {
134 dom: DomId { inner: dom },
135 node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
136 }
137 }
138
139 fn source_node_id(state: &DragState) -> Option<NodeId> {
142 state
143 .source_node
144 .as_option()
145 .and_then(|d| d.node.into_crate_internal())
146 }
147
148 fn file_path_str(state: &DragState) -> Option<&str> {
149 state.file_path.as_option().map(AzString::as_str)
150 }
151
152 #[test]
157 fn node_drag_maps_every_field() {
158 let mut ctx = node_ctx(3, 42);
159 node_drag_of(&mut ctx).current_drop_target = OptionDomNodeId::Some(dom_node(3, 9));
160
161 let state = DragState::from_context(&ctx).expect("node drag must map to DragState");
162
163 assert_eq!(state.drag_type, DragType::Node);
164 assert_eq!(state.source_node.as_option().map(|d| d.dom.inner), Some(3));
165 assert_eq!(source_node_id(&state), Some(NodeId::new(42)));
166 assert_eq!(
167 state.current_drop_target,
168 OptionDomNodeId::Some(dom_node(3, 9))
169 );
170 assert!(state.file_path.is_none());
172 }
173
174 #[test]
178 fn node_id_zero_is_not_encoded_as_none() {
179 let ctx = node_ctx(0, 0);
180 let state = DragState::from_context(&ctx).expect("node 0 is a valid drag source");
181
182 let source = state.source_node.as_option().expect("source must be Some");
183 assert_ne!(source.node, NodeHierarchyItemId::NONE);
184 assert_eq!(source.node.into_raw(), 1, "0-based 0 encodes to 1-based 1");
185 assert_eq!(source_node_id(&state), Some(NodeId::new(0)));
186 }
187
188 #[test]
192 fn node_id_max_encodable_round_trips() {
193 let max = usize::MAX - 1;
194 let ctx = node_ctx(usize::MAX, max);
195 let state = DragState::from_context(&ctx).expect("extreme ids still map");
196
197 let source = state.source_node.as_option().expect("source must be Some");
198 assert_eq!(source.dom.inner, usize::MAX);
199 assert_eq!(source.node.into_raw(), usize::MAX);
200 assert_eq!(source_node_id(&state), Some(NodeId::new(max)));
201 }
202
203 #[test]
206 fn node_drag_uses_current_not_previous_drop_target() {
207 let mut ctx = node_ctx(1, 5);
208 {
209 let drag = node_drag_of(&mut ctx);
210 drag.previous_drop_target = OptionDomNodeId::Some(dom_node(1, 100));
211 drag.current_drop_target = OptionDomNodeId::Some(dom_node(1, 200));
212 }
213
214 let state = DragState::from_context(&ctx).unwrap();
215 assert_eq!(
216 state.current_drop_target,
217 OptionDomNodeId::Some(dom_node(1, 200))
218 );
219 }
220
221 #[test]
222 fn node_drag_without_drop_target_stays_none() {
223 let ctx = node_ctx(1, 5);
224 let state = DragState::from_context(&ctx).unwrap();
225 assert!(state.current_drop_target.is_none());
226 }
227
228 #[test]
231 fn node_drag_cross_dom_drop_target_is_not_rewritten() {
232 let mut ctx = node_ctx(1, 5);
233 node_drag_of(&mut ctx).current_drop_target = OptionDomNodeId::Some(dom_node(9, 5));
234
235 let state = DragState::from_context(&ctx).unwrap();
236 assert_eq!(state.source_node.as_option().map(|d| d.dom.inner), Some(1));
237 assert_eq!(
238 state.current_drop_target.as_option().map(|d| d.dom.inner),
239 Some(9)
240 );
241 }
242
243 #[test]
246 fn node_drag_with_nan_and_infinite_positions_does_not_panic() {
247 for pos in [
248 LogicalPosition::new(f32::NAN, f32::NAN),
249 LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
250 LogicalPosition::new(f32::MAX, f32::MIN),
251 LogicalPosition::new(-0.0, f32::MIN_POSITIVE),
252 ] {
253 let mut ctx = DragContext::node_drag(
254 DomId::ROOT_ID,
255 NodeId::new(1),
256 pos,
257 DragData::new(),
258 0,
259 );
260 {
261 let drag = node_drag_of(&mut ctx);
262 drag.current_position = pos;
263 drag.drag_offset = pos;
264 }
265
266 let state = DragState::from_context(&ctx).expect("positions never gate the mapping");
267 assert_eq!(state.drag_type, DragType::Node);
268 assert_eq!(source_node_id(&state), Some(NodeId::new(1)));
269 }
270 }
271
272 #[test]
275 fn node_drag_with_file_like_payload_still_has_no_file_path() {
276 let mut data = DragData::new();
277 data.set_data("text/uri-list", b"file:///etc/passwd".to_vec());
278 data.set_text("/etc/passwd");
279 data.effect_allowed = DragEffect::All;
280
281 let ctx = DragContext::node_drag(
282 DomId::ROOT_ID,
283 NodeId::new(2),
284 LogicalPosition::zero(),
285 data,
286 0,
287 );
288
289 let state = DragState::from_context(&ctx).unwrap();
290 assert_eq!(state.drag_type, DragType::Node);
291 assert!(
292 state.file_path.is_none(),
293 "file_path must stay None for node drags regardless of payload"
294 );
295 }
296
297 #[test]
300 fn node_drag_drop_effect_flags_do_not_affect_mapping() {
301 let baseline = DragState::from_context(&node_ctx(1, 5)).unwrap();
302
303 for (accepted, effect) in [
304 (true, DropEffect::Move),
305 (true, DropEffect::Copy),
306 (false, DropEffect::Link),
307 (false, DropEffect::None),
308 ] {
309 let mut ctx = node_ctx(1, 5);
310 {
311 let drag = node_drag_of(&mut ctx);
312 drag.drop_accepted = accepted;
313 drag.drop_effect = effect;
314 }
315 assert_eq!(DragState::from_context(&ctx).unwrap(), baseline);
316 }
317 }
318
319 #[test]
324 fn file_drop_maps_first_path_and_has_no_source_node() {
325 let ctx = file_ctx(&["/tmp/a.txt"]);
326 let state = DragState::from_context(&ctx).expect("file drop must map");
327
328 assert_eq!(state.drag_type, DragType::File);
329 assert!(
330 state.source_node.is_none(),
331 "a file drop has no source DOM node"
332 );
333 assert_eq!(file_path_str(&state), Some("/tmp/a.txt"));
334 }
335
336 #[test]
339 fn file_drop_takes_the_first_path_not_the_last() {
340 let ctx = file_ctx(&["/first", "/second", "/third"]);
341 let state = DragState::from_context(&ctx).unwrap();
342 assert_eq!(file_path_str(&state), Some("/first"));
343 }
344
345 #[test]
346 fn file_drop_with_empty_file_list_yields_none_path() {
347 let ctx = file_ctx(&[]);
348 let state = DragState::from_context(&ctx).expect("an empty file drop still maps");
349
350 assert_eq!(state.drag_type, DragType::File);
351 assert!(state.source_node.is_none());
352 assert!(
353 state.file_path.is_none(),
354 "no files => no path (must not panic on first())"
355 );
356 }
357
358 #[test]
361 fn file_drop_empty_string_path_is_some_not_none() {
362 let ctx = file_ctx(&["", "/ignored"]);
363 let state = DragState::from_context(&ctx).unwrap();
364
365 assert!(state.file_path.is_some());
366 assert_eq!(file_path_str(&state), Some(""));
367 }
368
369 #[test]
373 fn file_drop_unicode_and_control_characters_round_trip() {
374 for path in [
375 "/tmp/\u{1F600}\u{1F3F4}\u{E0067}.png",
376 "/tmp/\u{202E}gnp.exe",
377 "/tmp/e\u{0301}\u{0327}\u{0308}.txt",
378 "/tmp/\u{4F60}\u{597D}/\u{043C}\u{0438}\u{0440}.txt",
379 "/tmp/line\nbreak\ttab.txt",
380 "/tmp/nul\u{0000}after.txt",
381 "\u{FEFF}/tmp/bom.txt",
382 ] {
383 let ctx = file_ctx(&[path]);
384 let state = DragState::from_context(&ctx).unwrap();
385
386 assert_eq!(
387 file_path_str(&state),
388 Some(path),
389 "path must round-trip byte-exactly"
390 );
391 assert_eq!(
392 file_path_str(&state).unwrap().len(),
393 path.len(),
394 "no truncation (e.g. at an embedded NUL)"
395 );
396 }
397 }
398
399 #[test]
400 fn file_drop_with_huge_path_round_trips() {
401 let huge = format!("/tmp/{}.txt", "x".repeat(64 * 1024));
402 let ctx = file_ctx(&[huge.as_str()]);
403 let state = DragState::from_context(&ctx).unwrap();
404
405 assert_eq!(file_path_str(&state), Some(huge.as_str()));
406 }
407
408 #[test]
409 fn file_drop_with_many_files_still_returns_the_first() {
410 let paths: Vec<String> = (0..10_000).map(|i| format!("/tmp/f{i}")).collect();
411 let ctx = DragContext::file_drop(
412 paths.iter().map(|p| s(p)).collect(),
413 LogicalPosition::zero(),
414 0,
415 );
416
417 let state = DragState::from_context(&ctx).unwrap();
418 assert_eq!(file_path_str(&state), Some("/tmp/f0"));
419 }
420
421 #[test]
422 fn file_drop_drop_target_passes_through() {
423 let mut ctx = file_ctx(&["/tmp/a"]);
424 file_drop_of(&mut ctx).drop_target = OptionDomNodeId::Some(dom_node(2, 77));
425
426 let state = DragState::from_context(&ctx).unwrap();
427 assert_eq!(
428 state.current_drop_target,
429 OptionDomNodeId::Some(dom_node(2, 77))
430 );
431 assert!(state.source_node.is_none());
433 }
434
435 #[test]
436 fn file_drop_with_nan_position_does_not_panic() {
437 let ctx = DragContext::file_drop(
438 vec![s("/tmp/a")],
439 LogicalPosition::new(f32::NAN, f32::INFINITY),
440 u64::MAX,
441 );
442
443 let state = DragState::from_context(&ctx).unwrap();
444 assert_eq!(state.drag_type, DragType::File);
445 assert_eq!(file_path_str(&state), Some("/tmp/a"));
446 }
447
448 #[test]
453 fn text_selection_drag_maps_to_none() {
454 let ctx = DragContext::text_selection(
455 DomId::ROOT_ID,
456 NodeId::new(4),
457 LogicalPosition::new(10.0, 10.0),
458 1,
459 );
460 assert!(DragState::from_context(&ctx).is_none());
461 }
462
463 #[test]
466 fn scrollbar_thumb_drag_maps_to_none_even_with_degenerate_metrics() {
467 for (track, content, viewport, offset) in [
468 (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32),
469 (f32::NAN, f32::NAN, f32::NAN, f32::NAN),
470 (f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN),
471 (-1.0, -1.0, -1.0, -1.0),
472 ] {
473 for axis in [ScrollbarAxis::Vertical, ScrollbarAxis::Horizontal] {
474 let ctx = DragContext::scrollbar_thumb(
475 DomId::ROOT_ID,
476 NodeId::new(0),
477 axis,
478 LogicalPosition::zero(),
479 offset,
480 track,
481 content,
482 viewport,
483 0,
484 );
485 assert!(DragState::from_context(&ctx).is_none());
486 }
487 }
488 }
489
490 #[test]
491 fn window_move_drag_maps_to_none() {
492 let ctx = DragContext::window_move(
493 LogicalPosition::zero(),
494 WindowPosition::Uninitialized,
495 0,
496 );
497 assert!(DragState::from_context(&ctx).is_none());
498 }
499
500 #[test]
501 fn window_resize_drag_maps_to_none_for_every_edge() {
502 for edge in [
503 WindowResizeEdge::Top,
504 WindowResizeEdge::Bottom,
505 WindowResizeEdge::Left,
506 WindowResizeEdge::Right,
507 WindowResizeEdge::TopLeft,
508 WindowResizeEdge::TopRight,
509 WindowResizeEdge::BottomLeft,
510 WindowResizeEdge::BottomRight,
511 ] {
512 let ctx = DragContext::new(
513 ActiveDragType::WindowResize(WindowResizeDrag {
514 edge,
515 start_position: LogicalPosition::zero(),
516 current_position: LogicalPosition::new(f32::NAN, 0.0),
517 initial_width: u32::MAX,
518 initial_height: 0,
519 }),
520 u64::MAX,
521 );
522 assert!(DragState::from_context(&ctx).is_none());
523 }
524 }
525
526 #[test]
533 fn from_context_is_pure_and_deterministic() {
534 for ctx in [node_ctx(1, 5), file_ctx(&["/tmp/a", "/tmp/b"])] {
535 let before = ctx.clone();
536
537 let first = DragState::from_context(&ctx);
538 let second = DragState::from_context(&ctx);
539
540 assert_eq!(first, second);
541 assert!(ctx == before, "from_context must not mutate the context");
542 }
543 }
544
545 #[test]
549 fn cancelled_flag_and_session_id_do_not_change_the_mapping() {
550 let mut ctx = node_ctx(1, 5);
551 let baseline = DragState::from_context(&ctx).unwrap();
552
553 ctx.cancelled = true;
554 ctx.session_id = u64::MAX;
555
556 let cancelled = DragState::from_context(&ctx)
557 .expect("cancelled drags still convert (DragState has no cancel bit)");
558 assert_eq!(cancelled, baseline);
559 }
560
561 #[test]
565 fn different_node_ids_produce_different_states() {
566 let a = DragState::from_context(&node_ctx(0, 0)).unwrap();
567 let b = DragState::from_context(&node_ctx(0, 1)).unwrap();
568 let c = DragState::from_context(&node_ctx(1, 0)).unwrap();
569
570 assert_ne!(a, b);
571 assert_ne!(a, c);
572 assert_ne!(b, c);
573 }
574
575 #[test]
578 fn node_and_file_states_never_collide() {
579 let mut node = node_ctx(0, 0);
580 node_drag_of(&mut node).current_drop_target = OptionDomNodeId::Some(dom_node(0, 3));
581 let mut file = file_ctx(&[]);
582 file_drop_of(&mut file).drop_target = OptionDomNodeId::Some(dom_node(0, 3));
583
584 let node_state = DragState::from_context(&node).unwrap();
585 let file_state = DragState::from_context(&file).unwrap();
586
587 assert_ne!(node_state, file_state);
588 assert_ne!(node_state.drag_type, file_state.drag_type);
589 assert_eq!(node_state.current_drop_target, file_state.current_drop_target);
590 }
591
592 #[test]
596 fn drag_state_clone_is_equal_and_independent() {
597 let ctx = file_ctx(&["/tmp/\u{1F600}.png"]);
598 let state = DragState::from_context(&ctx).unwrap();
599
600 let cloned = state.clone();
601 drop(state);
602
603 assert_eq!(file_path_str(&cloned), Some("/tmp/\u{1F600}.png"));
604 assert_eq!(cloned.drag_type, DragType::File);
605 }
606
607 #[test]
610 fn option_drag_state_round_trips() {
611 let state = DragState::from_context(&node_ctx(2, 8)).unwrap();
612
613 let wrapped: OptionDragState = Some(state.clone()).into();
614 assert!(wrapped.is_some());
615 let unwrapped: Option<DragState> = wrapped.into();
616 assert_eq!(unwrapped, Some(state));
617
618 let empty: OptionDragState = None.into();
619 assert!(empty.is_none());
620 let none_back: Option<DragState> = empty.into();
621 assert_eq!(none_back, None);
622 assert_eq!(OptionDragState::default(), OptionDragState::None);
623 }
624}