1pub(crate) mod overlay;
107
108use std::cell::Cell;
109use std::rc::Rc;
110
111use teksilo_canvas::{Point, Rect, Size, SizeProposal};
112use teksilo_core::accessibility::AccessNodeBuilder;
113use teksilo_core::accesskit::Role;
114use teksilo_core::build_context::BuildContext;
115use teksilo_core::signal::{Prop, Signal};
116use teksilo_core::styles::{
117 DropRegion, DropRegionSet, DropTargetDragState, DropTargetStyle, DropTargetStyleConfig,
118 DropTargetVariant, SharedDropTargetStyle, region_at,
119};
120use teksilo_core::widget::{
121 EventContext, LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement,
122};
123use teksilo_core::widget_builder::HandlerSet;
124use teksilo_core::widget_id::WidgetId;
125use teksilo_core::{DragPayload, DropFeedback};
126
127type AcceptPredicate = Rc<dyn Fn(&DragPayload) -> bool>;
128type DropCallback = Box<dyn FnMut(DragPayload, Point, &mut EventContext) -> bool>;
129type RegionDropCallback = Box<dyn FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool>;
130type LeaveCallback = Box<dyn FnMut(&mut EventContext)>;
131
132const DEFAULT_ZONE_SIZE_FACTOR: f32 = 0.2;
135
136pub struct DropRegionSpec {
140 hint: Option<PendingChild>,
141 enabled: Prop<bool>,
142}
143
144impl DropRegionSpec {
145 pub fn new() -> Self {
147 Self {
148 hint: None,
149 enabled: Prop::Static(true),
150 }
151 }
152
153 pub fn hint(mut self, widget: impl Widget + 'static) -> Self {
156 self.hint = Some(PendingChild::Deferred(Box::new(widget)));
157 self
158 }
159
160 pub fn hint_id(mut self, id: WidgetId) -> Self {
162 self.hint = Some(PendingChild::Id(id));
163 self
164 }
165
166 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
173 self.enabled = enabled.into();
174 self
175 }
176}
177
178impl Default for DropRegionSpec {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184impl std::fmt::Debug for DropRegionSpec {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.debug_struct("DropRegionSpec")
187 .field("has_hint", &self.hint.is_some())
188 .finish()
189 }
190}
191
192fn resolve_region_set(specs: &[(DropRegion, Prop<bool>)]) -> DropRegionSet {
196 if specs.is_empty() {
197 DropRegionSet::default()
198 } else {
199 specs
200 .iter()
201 .fold(DropRegionSet::none(), |set, (region, enabled)| {
202 if enabled.get() {
203 set.with(*region)
204 } else {
205 set
206 }
207 })
208 }
209}
210
211pub struct DropTarget {
214 pending_child: Option<PendingChild>,
215 child_id: Option<WidgetId>,
216 regions: Vec<(DropRegion, DropRegionSpec)>,
219 size_factor: f32,
220 accept_predicate: Option<AcceptPredicate>,
221 on_drop_callback: Option<DropCallback>,
222 on_region_drop_callback: Option<RegionDropCallback>,
223 on_drag_leave_callback: Option<LeaveCallback>,
224 out_targeted: Option<Signal<bool>>,
225 out_drag_state: Option<Signal<DropTargetDragState>>,
226 out_active_region: Option<Signal<Option<DropRegion>>>,
227 variant: DropTargetVariant,
228 style_override: Option<SharedDropTargetStyle>,
229 self_size: Rc<Cell<Size>>,
232 root_child_id: Option<WidgetId>,
233}
234
235impl DropTarget {
236 pub fn new() -> Self {
238 Self {
239 pending_child: None,
240 child_id: None,
241 regions: Vec::new(),
242 size_factor: DEFAULT_ZONE_SIZE_FACTOR,
243 accept_predicate: None,
244 on_drop_callback: None,
245 on_region_drop_callback: None,
246 on_drag_leave_callback: None,
247 out_targeted: None,
248 out_drag_state: None,
249 out_active_region: None,
250 variant: DropTargetVariant::Default,
251 style_override: None,
252 self_size: Rc::new(Cell::new(Size::ZERO)),
253 root_child_id: None,
254 }
255 }
256
257 fn set_region(&mut self, region: DropRegion, spec: DropRegionSpec) {
259 if let Some(slot) = self.regions.iter_mut().find(|(r, _)| *r == region) {
260 slot.1 = spec;
261 } else {
262 self.regions.push((region, spec));
263 }
264 }
265
266 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
270 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
271 self
272 }
273
274 pub fn child_id(mut self, id: WidgetId) -> Self {
276 self.pending_child = Some(PendingChild::Id(id));
277 self
278 }
279
280 pub fn region(
297 mut self,
298 region: DropRegion,
299 f: impl FnOnce(DropRegionSpec) -> DropRegionSpec,
300 ) -> Self {
301 self.set_region(region, f(DropRegionSpec::new()));
302 self
303 }
304
305 pub fn zone_size_factor(mut self, factor: f32) -> Self {
309 self.size_factor = factor.clamp(0.1, 1.0);
310 self
311 }
312
313 pub fn hint(mut self, widget: impl Widget + 'static) -> Self {
319 self.set_region(DropRegion::Center, DropRegionSpec::new().hint(widget));
320 self
321 }
322
323 pub fn hint_id(mut self, id: WidgetId) -> Self {
325 self.set_region(DropRegion::Center, DropRegionSpec::new().hint_id(id));
326 self
327 }
328
329 pub fn accept_any(mut self) -> Self {
333 self.accept_predicate = Some(Rc::new(|_| true));
334 self
335 }
336
337 pub fn accept_external(mut self) -> Self {
339 self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.is_external()));
340 self
341 }
342
343 pub fn accept_external_files(mut self) -> Self {
347 self.accept_predicate = Some(Rc::new(|p: &DragPayload| {
348 p.is_external() && (!p.files().is_empty() || offers_uri_list(p))
349 }));
350 self
351 }
352
353 pub fn accept_external_text(mut self) -> Self {
356 self.accept_predicate = Some(Rc::new(|p: &DragPayload| {
357 p.is_external() && (p.text().is_some() || offers_text(p))
358 }));
359 self
360 }
361
362 pub fn accept_external_extensions<I, S>(mut self, extensions: I) -> Self
367 where
368 I: IntoIterator<Item = S>,
369 S: AsRef<str>,
370 {
371 let exts: Vec<String> = extensions
372 .into_iter()
373 .map(|s| s.as_ref().to_string())
374 .collect();
375 self.accept_predicate = Some(Rc::new(move |p: &DragPayload| {
376 if !p.is_external() {
377 return false;
378 }
379 let files = p.files();
380 if !files.is_empty() {
381 return files.iter().all(|path| {
382 path.extension()
383 .and_then(|e| e.to_str())
384 .map(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
385 .unwrap_or(false)
386 });
387 }
388 offers_uri_list(p)
390 }));
391 self
392 }
393
394 pub fn accept_typed<T: 'static>(mut self) -> Self {
397 self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.has_typed::<T>()));
398 self
399 }
400
401 pub fn accept_when(mut self, f: impl Fn(&DragPayload) -> bool + 'static) -> Self {
403 self.accept_predicate = Some(Rc::new(f));
404 self
405 }
406
407 pub fn targeted_signal(mut self, signal: Signal<bool>) -> Self {
413 self.out_targeted = Some(signal);
414 self
415 }
416
417 pub fn drag_state_signal(mut self, signal: Signal<DropTargetDragState>) -> Self {
419 self.out_drag_state = Some(signal);
420 self
421 }
422
423 pub fn active_region_signal(mut self, signal: Signal<Option<DropRegion>>) -> Self {
427 self.out_active_region = Some(signal);
428 self
429 }
430
431 pub fn on_drop(
436 mut self,
437 f: impl FnMut(DragPayload, Point, &mut EventContext) -> bool + 'static,
438 ) -> Self {
439 self.on_drop_callback = Some(Box::new(f));
440 self
441 }
442
443 pub fn on_drop_typed<T: 'static>(
446 mut self,
447 mut f: impl FnMut(T, Point, &mut EventContext) -> bool + 'static,
448 ) -> Self {
449 self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.has_typed::<T>()));
450 self.on_drop_callback = Some(Box::new(move |mut payload, pos, ctx| {
451 match payload.take_typed::<T>() {
452 Some(value) => f(value, pos, ctx),
453 None => false,
454 }
455 }));
456 self
457 }
458
459 pub fn on_region_drop(
464 mut self,
465 f: impl FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool + 'static,
466 ) -> Self {
467 self.on_region_drop_callback = Some(Box::new(f));
468 self
469 }
470
471 pub fn on_drag_leave(mut self, f: impl FnMut(&mut EventContext) + 'static) -> Self {
474 self.on_drag_leave_callback = Some(Box::new(f));
475 self
476 }
477
478 pub fn variant(mut self, variant: DropTargetVariant) -> Self {
482 self.variant = variant;
483 self
484 }
485
486 pub fn style(mut self, style: impl DropTargetStyle) -> Self {
489 self.style_override = Some(Rc::new(style));
490 self
491 }
492}
493
494impl Default for DropTarget {
495 fn default() -> Self {
496 Self::new()
497 }
498}
499
500impl std::fmt::Debug for DropTarget {
501 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502 f.debug_struct("DropTarget")
503 .field("variant", &self.variant)
504 .field("regions", &self.regions.len())
505 .field("size_factor", &self.size_factor)
506 .field("has_accept_filter", &self.accept_predicate.is_some())
507 .finish()
508 }
509}
510
511fn offers_uri_list(p: &DragPayload) -> bool {
514 p.formats()
515 .iter()
516 .any(|f| f == "text/uri-list" || f.starts_with("text/uri-list"))
517}
518
519fn offers_text(p: &DragPayload) -> bool {
521 p.formats().iter().any(|f| {
522 f == "text/plain"
523 || f.starts_with("text/plain")
524 || f == "UTF8_STRING"
525 || f == "STRING"
526 || f == "TEXT"
527 })
528}
529
530impl Widget for DropTarget {
531 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
532 let drag_state = ctx.signal(DropTargetDragState::Idle);
533 let active_region = ctx.signal(None::<DropRegion>);
534
535 let content_id = match self.pending_child.take() {
537 Some(PendingChild::Id(id)) => id,
538 Some(PendingChild::Deferred(w)) => ctx.add_boxed(w),
539 None => panic!("DropTarget requires a child — call .child(...) or .child_id(...)"),
540 };
541 self.child_id = Some(content_id);
542
543 let declared_set = if self.regions.is_empty() {
547 DropRegionSet::default()
548 } else {
549 self.regions
550 .iter()
551 .fold(DropRegionSet::none(), |set, (r, _)| set.with(*r))
552 };
553
554 let mut region_hints: Vec<(DropRegion, WidgetId)> = Vec::new();
557 let mut enable_specs: Vec<(DropRegion, Prop<bool>)> = Vec::new();
558 for (region, spec) in std::mem::take(&mut self.regions) {
559 if let Some(hint) = spec.hint {
560 let id = match hint {
561 PendingChild::Id(id) => id,
562 PendingChild::Deferred(w) => ctx.add_boxed(w),
563 };
564 region_hints.push((region, id));
565 }
566 enable_specs.push((region, spec.enabled));
567 }
568
569 let style: SharedDropTargetStyle = self
571 .style_override
572 .clone()
573 .or_else(|| ctx.theme().style_slots.drop_target.clone())
574 .unwrap_or_else(|| Rc::new(crate::styles::RecipeDropTargetStyle::default()));
575
576 let cfg = DropTargetStyleConfig {
577 content_id,
578 drag_state: drag_state.clone(),
579 active_region: active_region.clone(),
580 regions: declared_set,
581 region_hints,
582 size_factor: self.size_factor,
583 variant: self.variant,
584 };
585 let root_id = style.make_body(&cfg, ctx);
586 self.root_child_id = Some(root_id);
587
588 let ds_hover = drag_state.clone();
592 let ds_leave = drag_state.clone();
593 let ar_hover = active_region.clone();
594 let ar_leave = active_region.clone();
595 let tgt_hover = self.out_targeted.clone();
596 let tgt_leave = self.out_targeted.clone();
597 let st_hover = self.out_drag_state.clone();
598 let st_leave = self.out_drag_state.clone();
599 let out_ar_hover = self.out_active_region.clone();
600 let out_ar_leave = self.out_active_region.clone();
601 let accept_hover = self.accept_predicate.clone();
602 let accept_drop = self.accept_predicate.clone();
603 let size_hover = self.self_size.clone();
604 let size_drop = self.self_size.clone();
605 let specs_hover = enable_specs.clone();
606 let specs_drop = enable_specs;
607 let factor = self.size_factor;
608 let mut on_leave_cb = self.on_drag_leave_callback.take();
609 let mut on_drop_cb = self.on_drop_callback.take();
610 let mut on_region_drop_cb = self.on_region_drop_callback.take();
611
612 let handlers = HandlerSet::new()
613 .clips_children(true)
614 .on_drag_hover(move |payload, pos, _ctx| {
615 let accepts = accept_hover.as_ref().is_none_or(|p| p(payload));
616 let new_region = if accepts {
621 region_at(
622 pos,
623 size_hover.get(),
624 resolve_region_set(&specs_hover),
625 factor,
626 )
627 } else {
628 None
629 };
630 let engaged = accepts && new_region.is_some();
637 let new_state = if !accepts {
638 DropTargetDragState::HoverReject
639 } else if engaged {
640 DropTargetDragState::HoverAccept
641 } else {
642 DropTargetDragState::Idle
643 };
644 if ds_hover.get() != new_state {
650 ds_hover.set(new_state);
651 if let Some(s) = &tgt_hover {
652 s.set(engaged);
653 }
654 if let Some(s) = &st_hover {
655 s.set(new_state);
656 }
657 }
658 if ar_hover.get() != new_region {
659 ar_hover.set(new_region);
660 if let Some(s) = &out_ar_hover {
661 s.set(new_region);
662 }
663 }
664 if engaged {
670 DropFeedback::Accept
671 } else {
672 DropFeedback::NoFeedback
673 }
674 })
675 .on_drag_leave(move |ctx| {
676 if ds_leave.get() != DropTargetDragState::Idle {
677 ds_leave.set(DropTargetDragState::Idle);
678 if let Some(s) = &tgt_leave {
679 s.set(false);
680 }
681 if let Some(s) = &st_leave {
682 s.set(DropTargetDragState::Idle);
683 }
684 }
685 if ar_leave.get().is_some() {
686 ar_leave.set(None);
687 if let Some(s) = &out_ar_leave {
688 s.set(None);
689 }
690 }
691 if let Some(cb) = &mut on_leave_cb {
692 cb(ctx);
693 }
694 })
695 .on_drop(move |payload, pos, ctx| {
696 let accepts = accept_drop.as_ref().is_none_or(|p| p(&payload));
699 if !accepts {
700 return false;
701 }
702 if let Some(cb) = &mut on_region_drop_cb {
709 match region_at(
710 pos,
711 size_drop.get(),
712 resolve_region_set(&specs_drop),
713 factor,
714 ) {
715 Some(region) => cb(region, payload, pos, ctx),
716 None => false,
717 }
718 } else if let Some(cb) = &mut on_drop_cb {
719 cb(payload, pos, ctx)
720 } else {
721 false
722 }
723 });
724 ctx.apply_self_handlers(handlers);
725
726 self.children()
727 }
728
729 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
730 self.child_id
738 .or(self.root_child_id)
739 .and_then(|id| ctx.child_layout_response(id, proposal))
740 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
741 }
742
743 fn place_children(
744 &self,
745 bounds: Rect,
746 _proposal: SizeProposal,
747 children: &mut [WidgetPlacement],
748 _ctx: &LayoutContext,
749 ) {
750 self.self_size.set(bounds.size());
753 for child in children.iter_mut() {
754 child.origin = bounds.origin();
755 child.size = bounds.size();
756 }
757 }
758
759 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
760 builder.set_role(Role::Group);
763 }
764
765 fn children(&self) -> Vec<WidgetId> {
766 self.root_child_id.into_iter().collect()
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::primitives::RectWidget;
774 use std::cell::{Cell, RefCell};
775 use std::path::PathBuf;
776 use std::rc::Rc;
777 use teksilo_canvas::Size;
778 use teksilo_core::widget_tree::WidgetTree;
779 use teksilo_core::{ExternalDropData, NoopWindowOps};
780 use teksilo_i18n::lit;
781
782 #[derive(Debug)]
784 struct Fixed(f32, f32);
785 impl Widget for Fixed {
786 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
787 Size::new(self.0, self.1).into()
788 }
789 }
790
791 #[derive(Debug)]
794 struct Marker;
795 impl Widget for Marker {
796 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
797 Size::new(40.0, 20.0).into()
798 }
799 fn paint(
800 &self,
801 bounds: teksilo_canvas::Rect,
802 canvas: &mut teksilo_canvas::Canvas,
803 _ctx: &teksilo_core::widget::PaintContext,
804 ) {
805 canvas.fill_rounded_rect(
806 bounds,
807 teksilo_tokens::CornerRadius::uniform(4.0),
808 teksilo_tokens::Color::RED,
809 );
810 }
811 }
812
813 fn themed_tree() -> WidgetTree {
814 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
815 }
816
817 #[test]
821 fn reports_child_natural_size() {
822 let mut tree = themed_tree();
823 let target = tree.add(
824 DropTarget::new()
825 .child(Fixed(200.0, 100.0))
826 .hint(Fixed(50.0, 20.0)),
827 );
828 tree.layout(SizeProposal::unspecified());
829 let b = tree.bounds(target);
830 assert!(
831 (b.width - 200.0).abs() < 0.01 && (b.height - 100.0).abs() < 0.01,
832 "expected 200x100, got {}x{}",
833 b.width,
834 b.height
835 );
836 }
837
838 #[test]
840 fn child_fills_bounds() {
841 let mut tree = themed_tree();
842 let inner = tree.add(RectWidget::new());
843 tree.add(DropTarget::new().child_id(inner));
844 tree.layout(SizeProposal::exact(300.0, 200.0));
845 let cb = tree.bounds(inner);
846 assert!((cb.width - 300.0).abs() < 0.01 && (cb.height - 200.0).abs() < 0.01);
847 }
848
849 #[test]
854 fn forwards_flexible_child_through_flex_parent() {
855 use crate::primitives::{Expand, Padding, ZStack};
856 let mut tree = themed_tree();
857 let inner = tree.add(RectWidget::new());
858 let expand = tree.add(Expand::new().child_id(inner));
859 let dt = tree.add(DropTarget::new().child_id(expand));
860 let pad = tree.add(Padding::uniform(16.0).child_id(dt));
861 let _z = tree.add(ZStack::new().child(RectWidget::new()).add_child(pad));
862 tree.layout(SizeProposal::exact(800.0, 600.0));
863 let b = tree.bounds(inner);
864 assert!(
865 b.width > 700.0 && b.height > 500.0,
866 "flexible child collapsed inside DropTarget: {b:?}"
867 );
868 }
869
870 #[test]
874 fn border_overlay_does_not_block_taps_to_content() {
875 use teksilo_core::event::PointerButton;
876 use teksilo_core::widget_builder::WidgetBuilder;
877 let tapped = Rc::new(Cell::new(false));
878 let t = tapped.clone();
879 let mut tree = themed_tree();
880 let inner = tree.add(RectWidget::new().on_tap(move |_e, _ctx| t.set(true)));
881 tree.add(DropTarget::new().child_id(inner));
882 tree.layout(SizeProposal::exact(200.0, 100.0));
883 let center = tree.bounds(inner).center();
884 tree.pointer_down_button(center, PointerButton::Primary);
885 tree.pointer_up_button(center, PointerButton::Primary);
886 assert!(
887 tapped.get(),
888 "the DropTarget border overlay must not eat taps meant for the wrapped content"
889 );
890 }
891
892 #[test]
894 fn external_file_accepted_fires_on_drop() {
895 let mut tree = themed_tree();
896 let dropped = Rc::new(Cell::new(false));
897 let d = dropped.clone();
898 tree.add(
899 DropTarget::new()
900 .child(RectWidget::new())
901 .accept_external_files()
902 .on_drop(move |_payload, _pos, _ctx| {
903 d.set(true);
904 true
905 }),
906 );
907 tree.layout(SizeProposal::exact(400.0, 300.0));
908
909 let mut noop = NoopWindowOps;
910 let data = ExternalDropData {
911 files: vec![PathBuf::from("/tmp/photo.png")],
912 ..Default::default()
913 };
914 let p = Point::new(200.0, 150.0);
915 tree.begin_external_drag(p, data.clone(), &mut noop);
916 tree.end_external_drag(p, data, &mut noop);
917
918 assert!(dropped.get(), "accepted file drop should fire on_drop");
919 }
920
921 #[test]
925 fn internal_typed_drop_extracts_value() {
926 #[derive(Debug, Clone, PartialEq)]
927 struct ProjectRef(u32);
928
929 #[derive(Debug)]
931 struct TypedDragSource;
932 impl Widget for TypedDragSource {
933 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
934 let self_id = ctx.self_id();
935 let hs = HandlerSet::new().on_drag(move |phase, ctx| {
936 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
937 ctx.start_drag(self_id, DragPayload::typed(ProjectRef(7)));
938 }
939 });
940 ctx.apply_self_handlers(hs);
941 Vec::new()
942 }
943 fn layout_response(
944 &self,
945 _proposal: SizeProposal,
946 _ctx: &LayoutContext,
947 ) -> LayoutResponse {
948 Size::new(100.0, 80.0).into()
949 }
950 }
951
952 let mut tree = themed_tree();
953 let got: Rc<RefCell<Option<ProjectRef>>> = Rc::new(RefCell::new(None));
954 let g = got.clone();
955 let target = DropTarget::new()
956 .child(Fixed(100.0, 80.0))
957 .on_drop_typed::<ProjectRef>(move |project, _pos, _ctx| {
958 *g.borrow_mut() = Some(project);
959 true
960 });
961 let source_id = tree.add(TypedDragSource);
962 let target_id = tree.add(target);
963 let es = tree.add(
964 crate::primitives::Expand::new()
965 .flex(1.0)
966 .child_id(source_id),
967 );
968 let et = tree.add(
969 crate::primitives::Expand::new()
970 .flex(1.0)
971 .child_id(target_id),
972 );
973 tree.add(crate::primitives::HStack::new().add_child(es).add_child(et));
974 tree.layout(SizeProposal::exact(400.0, 200.0));
975
976 let from = tree.bounds(source_id).center();
977 let to = tree.bounds(target_id).center();
978 tree.drag(from, to);
979
980 assert_eq!(
981 *got.borrow(),
982 Some(ProjectRef(7)),
983 "internal typed drop must extract and deliver the typed value",
984 );
985 }
986
987 #[test]
990 fn internal_typed_drop_rejects_other_type() {
991 #[derive(Debug, Clone)]
992 struct ProjectRef(u32);
993 #[derive(Debug, Clone)]
994 struct OtherRef(u32);
995
996 #[derive(Debug)]
997 struct OtherSource;
998 impl Widget for OtherSource {
999 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1000 let self_id = ctx.self_id();
1001 let hs = HandlerSet::new().on_drag(move |phase, ctx| {
1002 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
1003 ctx.start_drag(self_id, DragPayload::typed(OtherRef(1)));
1004 }
1005 });
1006 ctx.apply_self_handlers(hs);
1007 Vec::new()
1008 }
1009 fn layout_response(
1010 &self,
1011 _proposal: SizeProposal,
1012 _ctx: &LayoutContext,
1013 ) -> LayoutResponse {
1014 Size::new(100.0, 80.0).into()
1015 }
1016 }
1017
1018 let mut tree = themed_tree();
1019 let fired = Rc::new(Cell::new(false));
1020 let f = fired.clone();
1021 let target = DropTarget::new()
1022 .child(Fixed(100.0, 80.0))
1023 .on_drop_typed::<ProjectRef>(move |_p, _pos, _ctx| {
1024 f.set(true);
1025 true
1026 });
1027 let source_id = tree.add(OtherSource);
1028 let target_id = tree.add(target);
1029 let es = tree.add(
1030 crate::primitives::Expand::new()
1031 .flex(1.0)
1032 .child_id(source_id),
1033 );
1034 let et = tree.add(
1035 crate::primitives::Expand::new()
1036 .flex(1.0)
1037 .child_id(target_id),
1038 );
1039 tree.add(crate::primitives::HStack::new().add_child(es).add_child(et));
1040 tree.layout(SizeProposal::exact(400.0, 200.0));
1041
1042 let from = tree.bounds(source_id).center();
1043 let to = tree.bounds(target_id).center();
1044 tree.drag(from, to);
1045
1046 assert!(!fired.get(), "a payload of the wrong type must be rejected");
1047 }
1048
1049 #[test]
1052 fn extension_filter_rejects_wrong_type() {
1053 let mut tree = themed_tree();
1054 let dropped = Rc::new(Cell::new(false));
1055 let d = dropped.clone();
1056 tree.add(
1057 DropTarget::new()
1058 .child(RectWidget::new())
1059 .accept_external_extensions(["png"])
1060 .on_drop(move |_payload, _pos, _ctx| {
1061 d.set(true);
1062 true
1063 }),
1064 );
1065 tree.layout(SizeProposal::exact(400.0, 300.0));
1066
1067 let mut noop = NoopWindowOps;
1068 let data = ExternalDropData {
1069 files: vec![PathBuf::from("/tmp/notes.txt")],
1070 ..Default::default()
1071 };
1072 let p = Point::new(200.0, 150.0);
1073 tree.begin_external_drag(p, data.clone(), &mut noop);
1074 tree.end_external_drag(p, data, &mut noop);
1075
1076 assert!(!dropped.get(), "non-png drop must be rejected");
1077 }
1078
1079 #[test]
1082 fn is_targeted_tracks_accepted_hover() {
1083 let mut tree = themed_tree();
1084 let targeted = Signal::new(false);
1085 tree.add(
1086 DropTarget::new()
1087 .child(RectWidget::new())
1088 .accept_external_files()
1089 .targeted_signal(targeted.clone())
1090 .on_drop(|_p, _pos, _ctx| true),
1091 );
1092 tree.layout(SizeProposal::exact(400.0, 300.0));
1093
1094 let mut noop = NoopWindowOps;
1095 let data = ExternalDropData {
1096 files: vec![PathBuf::from("/tmp/a.png")],
1097 ..Default::default()
1098 };
1099 let p = Point::new(200.0, 150.0);
1100 tree.begin_external_drag(p, data.clone(), &mut noop);
1101 assert!(targeted.get(), "accepted hover sets is_targeted true");
1102 tree.end_external_drag(p, data, &mut noop);
1103 assert!(!targeted.get(), "drop/leave resets is_targeted");
1104 }
1105
1106 #[test]
1109 fn drag_state_reports_reject() {
1110 let mut tree = themed_tree();
1111 let state = Signal::new(DropTargetDragState::Idle);
1112 tree.add(
1113 DropTarget::new()
1114 .child(RectWidget::new())
1115 .accept_external_extensions(["png"])
1116 .drag_state_signal(state.clone())
1117 .on_drop(|_p, _pos, _ctx| true),
1118 );
1119 tree.layout(SizeProposal::exact(400.0, 300.0));
1120
1121 let mut noop = NoopWindowOps;
1122 let data = ExternalDropData {
1123 files: vec![PathBuf::from("/tmp/notes.txt")],
1124 ..Default::default()
1125 };
1126 let p = Point::new(200.0, 150.0);
1127 tree.begin_external_drag(p, data, &mut noop);
1128 assert_eq!(state.get(), DropTargetDragState::HoverReject);
1129 }
1130
1131 #[test]
1134 fn hint_paints_only_on_accepted_hover() {
1135 let mut tree = themed_tree();
1136 tree.add(
1137 DropTarget::new()
1138 .child(RectWidget::new())
1139 .hint(Marker)
1140 .accept_external_files()
1141 .on_drop(|_p, _pos, _ctx| true),
1142 );
1143 tree.layout(SizeProposal::exact(400.0, 300.0));
1144
1145 let red = teksilo_tokens::Color::RED.to_array();
1146 let frame = tree.render();
1147 assert!(
1148 !frame.shapes.iter().any(|s| s.color == red),
1149 "hint must be hidden at rest"
1150 );
1151
1152 let mut noop = NoopWindowOps;
1153 let data = ExternalDropData {
1154 files: vec![PathBuf::from("/tmp/a.png")],
1155 ..Default::default()
1156 };
1157 let p = Point::new(200.0, 150.0);
1158 tree.begin_external_drag(p, data, &mut noop);
1159 tree.layout(SizeProposal::exact(400.0, 300.0));
1160 let frame = tree.render();
1161 assert!(
1162 frame.shapes.iter().any(|s| s.color == red),
1163 "hint must paint while an accepted drag hovers"
1164 );
1165 }
1166
1167 #[test]
1170 fn builds_with_hint_and_prominent_variant() {
1171 let mut tree = themed_tree();
1172 let target = tree.add(
1173 DropTarget::new()
1174 .child(Fixed(160.0, 90.0))
1175 .hint(crate::primitives::TextWidget::new(lit!("Drop here")))
1176 .variant(DropTargetVariant::Prominent)
1177 .accept_any()
1178 .on_drop(|_p, _pos, _ctx| true),
1179 );
1180 tree.layout(SizeProposal::exact(160.0, 90.0));
1181 let _ = tree.render();
1182 let b = tree.bounds(target);
1183 assert!(b.width > 0.0 && b.height > 0.0);
1184 }
1185
1186 fn png_drop(tree: &mut WidgetTree, p: Point) {
1189 let mut noop = NoopWindowOps;
1190 let data = ExternalDropData {
1191 files: vec![PathBuf::from("/tmp/a.png")],
1192 ..Default::default()
1193 };
1194 tree.begin_external_drag(p, data.clone(), &mut noop);
1195 tree.end_external_drag(p, data, &mut noop);
1196 }
1197
1198 #[test]
1200 fn region_drop_reports_leading() {
1201 let mut tree = themed_tree();
1202 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1203 let g = got.clone();
1204 tree.add(
1205 DropTarget::new()
1206 .child(RectWidget::new())
1207 .region(DropRegion::Center, |z| z)
1208 .region(DropRegion::Leading, |z| z)
1209 .accept_external_files()
1210 .on_region_drop(move |region, _p, _pos, _ctx| {
1211 *g.borrow_mut() = Some(region);
1212 true
1213 }),
1214 );
1215 tree.layout(SizeProposal::exact(400.0, 300.0));
1216 png_drop(&mut tree, Point::new(20.0, 150.0));
1218 assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1219 }
1220
1221 #[test]
1223 fn region_drop_reports_center_in_middle() {
1224 let mut tree = themed_tree();
1225 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1226 let g = got.clone();
1227 tree.add(
1228 DropTarget::new()
1229 .child(RectWidget::new())
1230 .region(DropRegion::Center, |z| z)
1231 .region(DropRegion::Leading, |z| z)
1232 .region(DropRegion::Trailing, |z| z)
1233 .accept_external_files()
1234 .on_region_drop(move |region, _p, _pos, _ctx| {
1235 *g.borrow_mut() = Some(region);
1236 true
1237 }),
1238 );
1239 tree.layout(SizeProposal::exact(400.0, 300.0));
1240 png_drop(&mut tree, Point::new(200.0, 150.0));
1241 assert_eq!(*got.borrow(), Some(DropRegion::Center));
1242 }
1243
1244 #[test]
1248 fn zone_size_factor_widens_side_zones() {
1249 let mut tree = themed_tree();
1250 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1251 let g = got.clone();
1252 tree.add(
1253 DropTarget::new()
1254 .child(RectWidget::new())
1255 .zone_size_factor(0.5)
1256 .region(DropRegion::Center, |z| z)
1257 .region(DropRegion::Leading, |z| z)
1258 .accept_external_files()
1259 .on_region_drop(move |region, _p, _pos, _ctx| {
1260 *g.borrow_mut() = Some(region);
1261 true
1262 }),
1263 );
1264 tree.layout(SizeProposal::exact(400.0, 300.0));
1265 png_drop(&mut tree, Point::new(150.0, 150.0));
1267 assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1268 }
1269
1270 #[test]
1273 fn center_only_default_uses_plain_on_drop() {
1274 let mut tree = themed_tree();
1275 let dropped = Rc::new(Cell::new(false));
1276 let d = dropped.clone();
1277 tree.add(
1278 DropTarget::new()
1279 .child(RectWidget::new())
1280 .accept_external_files()
1281 .on_drop(move |_p, _pos, _ctx| {
1282 d.set(true);
1283 true
1284 }),
1285 );
1286 tree.layout(SizeProposal::exact(400.0, 300.0));
1287 png_drop(&mut tree, Point::new(20.0, 150.0));
1288 assert!(
1289 dropped.get(),
1290 "center-only default must route to plain on_drop"
1291 );
1292 }
1293
1294 #[test]
1296 fn active_region_signal_tracks_and_resets() {
1297 let mut tree = themed_tree();
1298 let region = Signal::new(None);
1299 tree.add(
1300 DropTarget::new()
1301 .child(RectWidget::new())
1302 .region(DropRegion::Center, |z| z)
1303 .region(DropRegion::Leading, |z| z)
1304 .accept_external_files()
1305 .active_region_signal(region.clone())
1306 .on_drop(|_p, _pos, _ctx| true),
1307 );
1308 tree.layout(SizeProposal::exact(400.0, 300.0));
1309
1310 let mut noop = NoopWindowOps;
1311 let data = ExternalDropData {
1312 files: vec![PathBuf::from("/tmp/a.png")],
1313 ..Default::default()
1314 };
1315 let p = Point::new(20.0, 150.0);
1316 tree.begin_external_drag(p, data.clone(), &mut noop);
1317 assert_eq!(region.get(), Some(DropRegion::Leading));
1318 tree.end_external_drag(p, data, &mut noop);
1319 assert_eq!(region.get(), None, "leave resets the active region");
1320 }
1321
1322 #[test]
1325 fn per_region_hint_paints_only_for_its_zone() {
1326 let mut tree = themed_tree();
1327 tree.add(
1328 DropTarget::new()
1329 .child(RectWidget::new())
1330 .region(DropRegion::Center, |z| z)
1331 .region(DropRegion::Leading, |z| z.hint(Marker))
1332 .accept_external_files()
1333 .on_drop(|_p, _pos, _ctx| true),
1334 );
1335 tree.layout(SizeProposal::exact(400.0, 300.0));
1336 let red = teksilo_tokens::Color::RED.to_array();
1337
1338 let mut noop = NoopWindowOps;
1339 let data = ExternalDropData {
1340 files: vec![PathBuf::from("/tmp/a.png")],
1341 ..Default::default()
1342 };
1343
1344 let center = Point::new(200.0, 150.0);
1346 tree.begin_external_drag(center, data.clone(), &mut noop);
1347 tree.layout(SizeProposal::exact(400.0, 300.0));
1348 assert!(
1349 !tree.render().shapes.iter().any(|s| s.color == red),
1350 "Leading hint must not paint while hovering the centre"
1351 );
1352 tree.end_external_drag(center, data.clone(), &mut noop);
1353
1354 let lead = Point::new(20.0, 150.0);
1356 tree.begin_external_drag(lead, data.clone(), &mut noop);
1357 tree.layout(SizeProposal::exact(400.0, 300.0));
1358 assert!(
1359 tree.render().shapes.iter().any(|s| s.color == red),
1360 "Leading hint must paint while hovering the leading zone"
1361 );
1362 tree.end_external_drag(lead, data, &mut noop);
1363 }
1364
1365 #[test]
1370 fn side_only_dead_middle_rejects_drop_and_hover() {
1371 let mut tree = themed_tree();
1372 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1373 let g = got.clone();
1374 let region = Signal::new(None);
1375 let targeted = Signal::new(false);
1376 tree.add(
1377 DropTarget::new()
1378 .child(RectWidget::new())
1379 .zone_size_factor(0.2)
1380 .region(DropRegion::Leading, |z| z)
1381 .region(DropRegion::Trailing, |z| z)
1382 .accept_external_files()
1383 .active_region_signal(region.clone())
1384 .targeted_signal(targeted.clone())
1385 .on_region_drop(move |r, _p, _pos, _ctx| {
1386 *g.borrow_mut() = Some(r);
1387 true
1388 }),
1389 );
1390 tree.layout(SizeProposal::exact(400.0, 300.0));
1391
1392 let mut noop = NoopWindowOps;
1393 let data = ExternalDropData {
1394 files: vec![PathBuf::from("/tmp/a.png")],
1395 ..Default::default()
1396 };
1397 tree.begin_external_drag(Point::new(20.0, 150.0), data.clone(), &mut noop);
1399 assert_eq!(region.get(), Some(DropRegion::Leading));
1400 assert!(targeted.get(), "hovering an enabled zone engages");
1401 tree.update_external_drag(Point::new(200.0, 150.0), &mut noop);
1403 assert_eq!(region.get(), None, "dead middle reports no zone");
1404 assert!(!targeted.get(), "dead middle must not engage this target");
1405 tree.end_external_drag(Point::new(200.0, 150.0), data, &mut noop);
1407 assert_eq!(
1408 *got.borrow(),
1409 None,
1410 "a dead-middle drop must be rejected, not fabricated as Center"
1411 );
1412 }
1413
1414 #[test]
1417 fn top_and_bottom_zones_route() {
1418 for (y, expected) in [(10.0_f32, DropRegion::Top), (290.0_f32, DropRegion::Bottom)] {
1419 let mut tree = themed_tree();
1420 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1421 let g = got.clone();
1422 tree.add(
1423 DropTarget::new()
1424 .child(RectWidget::new())
1425 .region(DropRegion::Top, |z| z)
1426 .region(DropRegion::Bottom, |z| z)
1427 .region(DropRegion::Center, |z| z)
1428 .accept_external_files()
1429 .on_region_drop(move |r, _p, _pos, _ctx| {
1430 *g.borrow_mut() = Some(r);
1431 true
1432 }),
1433 );
1434 tree.layout(SizeProposal::exact(400.0, 300.0));
1435 png_drop(&mut tree, Point::new(200.0, y));
1437 assert_eq!(*got.borrow(), Some(expected));
1438 }
1439 }
1440
1441 #[test]
1444 fn rejected_hover_reports_no_region() {
1445 let mut tree = themed_tree();
1446 let region = Signal::new(None);
1447 let state = Signal::new(DropTargetDragState::Idle);
1448 tree.add(
1449 DropTarget::new()
1450 .child(RectWidget::new())
1451 .region(DropRegion::Leading, |z| z)
1452 .region(DropRegion::Center, |z| z)
1453 .accept_external_extensions(["png"])
1454 .active_region_signal(region.clone())
1455 .drag_state_signal(state.clone())
1456 .on_drop(|_p, _pos, _ctx| true),
1457 );
1458 tree.layout(SizeProposal::exact(400.0, 300.0));
1459
1460 let mut noop = NoopWindowOps;
1461 let data = ExternalDropData {
1463 files: vec![PathBuf::from("/tmp/notes.txt")],
1464 ..Default::default()
1465 };
1466 tree.begin_external_drag(Point::new(20.0, 150.0), data, &mut noop);
1467 assert_eq!(state.get(), DropTargetDragState::HoverReject);
1468 assert_eq!(
1469 region.get(),
1470 None,
1471 "a rejected hover must report no zone even inside a would-be strip"
1472 );
1473 }
1474
1475 #[test]
1479 fn reactive_zone_enabled_gates_hit_testing() {
1480 let mut tree = themed_tree();
1481 let leading_on = Signal::new(true);
1482 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1483 let g = got.clone();
1484 tree.add(
1485 DropTarget::new()
1486 .child(RectWidget::new())
1487 .zone_size_factor(0.2)
1488 .region(DropRegion::Leading, |z| z.enabled(leading_on.clone()))
1489 .region(DropRegion::Center, |z| z)
1490 .accept_external_files()
1491 .on_region_drop(move |r, _p, _pos, _ctx| {
1492 *g.borrow_mut() = Some(r);
1493 true
1494 }),
1495 );
1496 tree.layout(SizeProposal::exact(400.0, 300.0));
1497 png_drop(&mut tree, Point::new(20.0, 150.0));
1499 assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1500 leading_on.set(false);
1502 *got.borrow_mut() = None;
1503 png_drop(&mut tree, Point::new(20.0, 150.0));
1504 assert_eq!(
1505 *got.borrow(),
1506 Some(DropRegion::Center),
1507 "a live-disabled zone falls through to the next enabled zone"
1508 );
1509 }
1510}