1pub(crate) mod overlay;
128
129use std::cell::Cell;
130use std::rc::Rc;
131
132use teksilo_canvas::{Point, Rect, Size, SizeProposal};
133use teksilo_core::accessibility::AccessNodeBuilder;
134use teksilo_core::accesskit::Role;
135use teksilo_core::build_context::BuildContext;
136use teksilo_core::signal::{Prop, Signal};
137use teksilo_core::styles::{
138 DropRegion, DropRegionSet, DropTargetDragState, DropTargetStyle, DropTargetStyleConfig,
139 DropTargetVariant, SharedDropTargetStyle,
140};
141use teksilo_core::widget::{
142 EventContext, LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement,
143};
144use teksilo_core::widget_builder::HandlerSet;
145use teksilo_core::widget_id::WidgetId;
146use teksilo_core::{DragPayload, DropFeedback};
147
148type AcceptPredicate = Rc<dyn Fn(&DragPayload) -> bool>;
149type DropCallback = Box<dyn FnMut(DragPayload, Point, &mut EventContext) -> bool>;
150type RegionDropCallback = Box<dyn FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool>;
151type LeaveCallback = Box<dyn FnMut(&mut EventContext)>;
152
153const DEFAULT_ZONE_SIZE_FACTOR: f32 = 0.2;
156
157pub fn band_depth(extent: f32, factor: f32, floor: f32) -> f32 {
178 if !extent.is_finite() || extent <= 0.0 {
179 return 0.0;
180 }
181 let fraction = extent * factor.clamp(0.1, 1.0);
186 fraction.max(floor.min(extent / 3.0))
187}
188
189pub fn region_at_floored(
196 local: Point,
197 size: Size,
198 set: teksilo_core::styles::DropRegionSet,
199 factor: f32,
200 floor: f32,
201) -> Option<DropRegion> {
202 let ex = band_depth(size.width, factor, floor);
203 let ey = band_depth(size.height, factor, floor);
204 if set.leading && local.x < ex {
205 Some(DropRegion::Leading)
206 } else if set.trailing && local.x > size.width - ex {
207 Some(DropRegion::Trailing)
208 } else if set.top && local.y < ey {
209 Some(DropRegion::Top)
210 } else if set.bottom && local.y > size.height - ey {
211 Some(DropRegion::Bottom)
212 } else if set.center {
213 Some(DropRegion::Center)
214 } else {
215 None
216 }
217}
218
219pub fn region_rect_floored(region: DropRegion, bounds: Rect, factor: f32, floor: f32) -> Rect {
223 let ex = band_depth(bounds.width, factor, floor);
224 let ey = band_depth(bounds.height, factor, floor);
225 match region {
226 DropRegion::Center => bounds,
227 DropRegion::Leading => Rect::new(bounds.x, bounds.y, ex, bounds.height),
228 DropRegion::Trailing => {
229 Rect::new(bounds.x + bounds.width - ex, bounds.y, ex, bounds.height)
230 }
231 DropRegion::Top => Rect::new(bounds.x, bounds.y, bounds.width, ey),
232 DropRegion::Bottom => Rect::new(bounds.x, bounds.y + bounds.height - ey, bounds.width, ey),
233 }
234}
235
236pub struct DropRegionSpec {
240 hint: Option<PendingChild>,
241 enabled: Prop<bool>,
242}
243
244impl DropRegionSpec {
245 pub fn new() -> Self {
247 Self {
248 hint: None,
249 enabled: Prop::Static(true),
250 }
251 }
252
253 pub fn hint(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
256 self.hint = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
257 self
258 }
259
260 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
267 self.enabled = enabled.into();
268 self
269 }
270}
271
272impl Default for DropRegionSpec {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278impl std::fmt::Debug for DropRegionSpec {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 f.debug_struct("DropRegionSpec")
281 .field("has_hint", &self.hint.is_some())
282 .finish()
283 }
284}
285
286fn resolve_region_set(specs: &[(DropRegion, Prop<bool>)]) -> DropRegionSet {
290 if specs.is_empty() {
291 DropRegionSet::default()
292 } else {
293 specs
294 .iter()
295 .fold(DropRegionSet::none(), |set, (region, enabled)| {
296 if enabled.get() {
297 set.with(*region)
298 } else {
299 set
300 }
301 })
302 }
303}
304
305pub struct DropTarget {
308 pending_child: Option<PendingChild>,
309 child_id: Option<WidgetId>,
310 regions: Vec<(DropRegion, DropRegionSpec)>,
313 size_factor: f32,
314 accept_predicate: Option<AcceptPredicate>,
315 on_drop_callback: Option<DropCallback>,
316 on_region_drop_callback: Option<RegionDropCallback>,
317 on_drag_leave_callback: Option<LeaveCallback>,
318 out_targeted: Option<Signal<bool>>,
319 out_drag_state: Option<Signal<DropTargetDragState>>,
320 out_active_region: Option<Signal<Option<DropRegion>>>,
321 variant: DropTargetVariant,
322 style_override: Option<SharedDropTargetStyle>,
323 self_size: Rc<Cell<Size>>,
326 root_child_id: Option<WidgetId>,
327}
328
329impl DropTarget {
330 pub fn new() -> Self {
332 Self {
333 pending_child: None,
334 child_id: None,
335 regions: Vec::new(),
336 size_factor: DEFAULT_ZONE_SIZE_FACTOR,
337 accept_predicate: None,
338 on_drop_callback: None,
339 on_region_drop_callback: None,
340 on_drag_leave_callback: None,
341 out_targeted: None,
342 out_drag_state: None,
343 out_active_region: None,
344 variant: DropTargetVariant::Default,
345 style_override: None,
346 self_size: Rc::new(Cell::new(Size::ZERO)),
347 root_child_id: None,
348 }
349 }
350
351 fn set_region(&mut self, region: DropRegion, spec: DropRegionSpec) {
353 if let Some(slot) = self.regions.iter_mut().find(|(r, _)| *r == region) {
354 slot.1 = spec;
355 } else {
356 self.regions.push((region, spec));
357 }
358 }
359
360 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
364 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
365 self
366 }
367 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
374 match widget {
375 Some(w) => self.child(w),
376 None => self,
377 }
378 }
379
380 pub fn region(
397 mut self,
398 region: DropRegion,
399 f: impl FnOnce(DropRegionSpec) -> DropRegionSpec,
400 ) -> Self {
401 self.set_region(region, f(DropRegionSpec::new()));
402 self
403 }
404
405 pub fn zone_size_factor(mut self, factor: f32) -> Self {
409 self.size_factor = factor.clamp(0.1, 1.0);
410 self
411 }
412
413 pub fn hint(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
419 self.set_region(DropRegion::Center, DropRegionSpec::new().hint(widget));
420 self
421 }
422
423 pub fn accept_any(mut self) -> Self {
427 self.accept_predicate = Some(Rc::new(|_| true));
428 self
429 }
430
431 pub fn accept_external(mut self) -> Self {
433 self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.is_external()));
434 self
435 }
436
437 pub fn accept_external_files(mut self) -> Self {
441 self.accept_predicate = Some(Rc::new(|p: &DragPayload| {
442 p.is_external() && (!p.files().is_empty() || offers_uri_list(p))
443 }));
444 self
445 }
446
447 pub fn accept_external_text(mut self) -> Self {
450 self.accept_predicate = Some(Rc::new(|p: &DragPayload| {
451 p.is_external() && (p.text().is_some() || offers_text(p))
452 }));
453 self
454 }
455
456 pub fn accept_external_extensions<I, S>(mut self, extensions: I) -> Self
461 where
462 I: IntoIterator<Item = S>,
463 S: AsRef<str>,
464 {
465 let exts: Vec<String> = extensions
466 .into_iter()
467 .map(|s| s.as_ref().to_string())
468 .collect();
469 self.accept_predicate = Some(Rc::new(move |p: &DragPayload| {
470 if !p.is_external() {
471 return false;
472 }
473 let files = p.files();
474 if !files.is_empty() {
475 return files.iter().all(|path| {
476 path.extension()
477 .and_then(|e| e.to_str())
478 .map(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
479 .unwrap_or(false)
480 });
481 }
482 offers_uri_list(p)
484 }));
485 self
486 }
487
488 pub fn accept_typed<T: 'static>(mut self) -> Self {
491 self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.has_typed::<T>()));
492 self
493 }
494
495 pub fn accept_when(mut self, f: impl Fn(&DragPayload) -> bool + 'static) -> Self {
497 self.accept_predicate = Some(Rc::new(f));
498 self
499 }
500
501 pub fn targeted_signal(mut self, signal: Signal<bool>) -> Self {
507 self.out_targeted = Some(signal);
508 self
509 }
510
511 pub fn drag_state_signal(mut self, signal: Signal<DropTargetDragState>) -> Self {
513 self.out_drag_state = Some(signal);
514 self
515 }
516
517 pub fn active_region_signal(mut self, signal: Signal<Option<DropRegion>>) -> Self {
521 self.out_active_region = Some(signal);
522 self
523 }
524
525 pub fn on_drop(
530 mut self,
531 f: impl FnMut(DragPayload, Point, &mut EventContext) -> bool + 'static,
532 ) -> Self {
533 self.on_drop_callback = Some(Box::new(f));
534 self
535 }
536
537 pub fn on_drop_typed<T: 'static>(
540 mut self,
541 mut f: impl FnMut(T, Point, &mut EventContext) -> bool + 'static,
542 ) -> Self {
543 self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.has_typed::<T>()));
544 self.on_drop_callback = Some(Box::new(move |mut payload, pos, ctx| {
545 match payload.take_typed::<T>() {
546 Some(value) => f(value, pos, ctx),
547 None => false,
548 }
549 }));
550 self
551 }
552
553 pub fn on_region_drop(
558 mut self,
559 f: impl FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool + 'static,
560 ) -> Self {
561 self.on_region_drop_callback = Some(Box::new(f));
562 self
563 }
564
565 pub fn on_drag_leave(mut self, f: impl FnMut(&mut EventContext) + 'static) -> Self {
568 self.on_drag_leave_callback = Some(Box::new(f));
569 self
570 }
571
572 pub fn variant(mut self, variant: DropTargetVariant) -> Self {
576 self.variant = variant;
577 self
578 }
579
580 pub fn style(mut self, style: impl DropTargetStyle) -> Self {
583 self.style_override = Some(Rc::new(style));
584 self
585 }
586}
587
588impl Default for DropTarget {
589 fn default() -> Self {
590 Self::new()
591 }
592}
593
594impl std::fmt::Debug for DropTarget {
595 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
596 f.debug_struct("DropTarget")
597 .field("variant", &self.variant)
598 .field("regions", &self.regions.len())
599 .field("size_factor", &self.size_factor)
600 .field("has_accept_filter", &self.accept_predicate.is_some())
601 .finish()
602 }
603}
604
605fn offers_uri_list(p: &DragPayload) -> bool {
608 p.formats()
609 .iter()
610 .any(|f| f == "text/uri-list" || f.starts_with("text/uri-list"))
611}
612
613fn offers_text(p: &DragPayload) -> bool {
615 p.formats().iter().any(|f| {
616 f == "text/plain"
617 || f.starts_with("text/plain")
618 || f == "UTF8_STRING"
619 || f == "STRING"
620 || f == "TEXT"
621 })
622}
623
624impl Widget for DropTarget {
625 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
626 let drag_state = ctx.signal(DropTargetDragState::Idle);
627 let active_region = ctx.signal(None::<DropRegion>);
628
629 let content_id = match self.pending_child.take() {
631 Some(PendingChild::Id(id)) => id,
632 Some(PendingChild::Deferred(w)) => ctx.add_boxed(w),
633 None => panic!("DropTarget requires a child — call .child(...) or .child(...)"),
634 };
635 self.child_id = Some(content_id);
636
637 let declared_set = if self.regions.is_empty() {
641 DropRegionSet::default()
642 } else {
643 self.regions
644 .iter()
645 .fold(DropRegionSet::none(), |set, (r, _)| set.with(*r))
646 };
647
648 let mut region_hints: Vec<(DropRegion, WidgetId)> = Vec::new();
651 let mut enable_specs: Vec<(DropRegion, Prop<bool>)> = Vec::new();
652 for (region, spec) in std::mem::take(&mut self.regions) {
653 if let Some(hint) = spec.hint {
654 let id = match hint {
655 PendingChild::Id(id) => id,
656 PendingChild::Deferred(w) => ctx.add_boxed(w),
657 };
658 region_hints.push((region, id));
659 }
660 enable_specs.push((region, spec.enabled));
661 }
662
663 let style: SharedDropTargetStyle = self
665 .style_override
666 .clone()
667 .or_else(|| ctx.theme().style_slots.drop_target.clone())
668 .unwrap_or_else(|| {
669 Rc::new(crate::styles::RecipeDropTargetStyle::for_tokens(
670 &ctx.theme().input,
671 ))
672 });
673
674 let cfg = DropTargetStyleConfig {
675 content_id,
676 drag_state: drag_state.clone(),
677 active_region: active_region.clone(),
678 regions: declared_set,
679 region_hints,
680 size_factor: self.size_factor,
681 variant: self.variant,
682 };
683 let root_id = style.make_body(&cfg, ctx);
684 self.root_child_id = Some(root_id);
685
686 let ds_hover = drag_state.clone();
690 let ds_leave = drag_state.clone();
691 let ar_hover = active_region.clone();
692 let ar_leave = active_region.clone();
693 let tgt_hover = self.out_targeted.clone();
694 let tgt_leave = self.out_targeted.clone();
695 let st_hover = self.out_drag_state.clone();
696 let st_leave = self.out_drag_state.clone();
697 let out_ar_hover = self.out_active_region.clone();
698 let out_ar_leave = self.out_active_region.clone();
699 let accept_hover = self.accept_predicate.clone();
700 let accept_drop = self.accept_predicate.clone();
701 let size_hover = self.self_size.clone();
702 let size_drop = self.self_size.clone();
703 let specs_hover = enable_specs.clone();
704 let specs_drop = enable_specs;
705 let factor = self.size_factor;
706 let zone_floor = ctx.theme().input.target_size;
711 let mut on_leave_cb = self.on_drag_leave_callback.take();
712 let mut on_drop_cb = self.on_drop_callback.take();
713 let mut on_region_drop_cb = self.on_region_drop_callback.take();
714
715 let handlers = HandlerSet::new()
716 .clips_children(true)
717 .on_drag_hover(move |payload, pos, _ctx| {
718 let accepts = accept_hover.as_ref().is_none_or(|p| p(payload));
719 let new_region = if accepts {
724 region_at_floored(
725 pos,
726 size_hover.get(),
727 resolve_region_set(&specs_hover),
728 factor,
729 zone_floor,
730 )
731 } else {
732 None
733 };
734 let engaged = accepts && new_region.is_some();
741 let new_state = if !accepts {
742 DropTargetDragState::HoverReject
743 } else if engaged {
744 DropTargetDragState::HoverAccept
745 } else {
746 DropTargetDragState::Idle
747 };
748 if ds_hover.get() != new_state {
754 ds_hover.set(new_state);
755 if let Some(s) = &tgt_hover {
756 s.set(engaged);
757 }
758 if let Some(s) = &st_hover {
759 s.set(new_state);
760 }
761 }
762 if ar_hover.get() != new_region {
763 ar_hover.set(new_region);
764 if let Some(s) = &out_ar_hover {
765 s.set(new_region);
766 }
767 }
768 if engaged {
774 DropFeedback::Accept
775 } else {
776 DropFeedback::NoFeedback
777 }
778 })
779 .on_drag_leave(move |ctx| {
780 if ds_leave.get() != DropTargetDragState::Idle {
781 ds_leave.set(DropTargetDragState::Idle);
782 if let Some(s) = &tgt_leave {
783 s.set(false);
784 }
785 if let Some(s) = &st_leave {
786 s.set(DropTargetDragState::Idle);
787 }
788 }
789 if ar_leave.get().is_some() {
790 ar_leave.set(None);
791 if let Some(s) = &out_ar_leave {
792 s.set(None);
793 }
794 }
795 if let Some(cb) = &mut on_leave_cb {
796 cb(ctx);
797 }
798 })
799 .on_drop(move |payload, pos, ctx| {
800 let accepts = accept_drop.as_ref().is_none_or(|p| p(&payload));
803 if !accepts {
804 return false;
805 }
806 if let Some(cb) = &mut on_region_drop_cb {
813 match region_at_floored(
814 pos,
815 size_drop.get(),
816 resolve_region_set(&specs_drop),
817 factor,
818 zone_floor,
819 ) {
820 Some(region) => cb(region, payload, pos, ctx),
821 None => false,
822 }
823 } else if let Some(cb) = &mut on_drop_cb {
824 cb(payload, pos, ctx)
825 } else {
826 false
827 }
828 });
829 ctx.apply_self_handlers(handlers);
830
831 self.children()
832 }
833
834 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
835 self.child_id
843 .or(self.root_child_id)
844 .and_then(|id| ctx.child_layout_response(id, proposal))
845 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
846 }
847
848 fn place_children(
849 &self,
850 bounds: Rect,
851 _proposal: SizeProposal,
852 children: &mut [WidgetPlacement],
853 _ctx: &LayoutContext,
854 ) {
855 self.self_size.set(bounds.size());
858 for child in children.iter_mut() {
859 child.origin = bounds.origin();
860 child.size = bounds.size();
861 }
862 }
863
864 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
865 builder.set_role(Role::Group);
868 }
869
870 fn children(&self) -> Vec<WidgetId> {
871 self.root_child_id.into_iter().collect()
872 }
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878 use crate::primitives::RectWidget;
879 use std::cell::{Cell, RefCell};
880 use std::path::PathBuf;
881 use std::rc::Rc;
882 use teksilo_canvas::Size;
883 use teksilo_core::widget_tree::WidgetTree;
884 use teksilo_core::{ExternalDropData, NoopWindowOps};
885 use teksilo_i18n::lit;
886
887 #[derive(Debug)]
889 struct Fixed(f32, f32);
890 impl Widget for Fixed {
891 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
892 Size::new(self.0, self.1).into()
893 }
894 }
895
896 #[derive(Debug)]
899 struct Marker;
900 impl Widget for Marker {
901 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
902 Size::new(40.0, 20.0).into()
903 }
904 fn paint(
905 &self,
906 bounds: teksilo_canvas::Rect,
907 canvas: &mut teksilo_canvas::Canvas,
908 _ctx: &teksilo_core::widget::PaintContext,
909 ) {
910 canvas.fill_rounded_rect(
911 bounds,
912 teksilo_tokens::CornerRadius::uniform(4.0),
913 teksilo_tokens::Color::RED,
914 );
915 }
916 }
917
918 fn themed_tree() -> WidgetTree {
919 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
920 }
921
922 #[test]
926 fn reports_child_natural_size() {
927 let mut tree = themed_tree();
928 let target = tree.add(
929 DropTarget::new()
930 .child(Fixed(200.0, 100.0))
931 .hint(Fixed(50.0, 20.0)),
932 );
933 tree.layout(SizeProposal::unspecified());
934 let b = tree.bounds(target);
935 assert!(
936 (b.width - 200.0).abs() < 0.01 && (b.height - 100.0).abs() < 0.01,
937 "expected 200x100, got {}x{}",
938 b.width,
939 b.height
940 );
941 }
942
943 #[test]
945 fn child_fills_bounds() {
946 let mut tree = themed_tree();
947 let inner = tree.add(RectWidget::new());
948 tree.add(DropTarget::new().child(inner));
949 tree.layout(SizeProposal::exact(300.0, 200.0));
950 let cb = tree.bounds(inner);
951 assert!((cb.width - 300.0).abs() < 0.01 && (cb.height - 200.0).abs() < 0.01);
952 }
953
954 #[test]
959 fn forwards_flexible_child_through_flex_parent() {
960 use crate::primitives::{Expand, Padding, ZStack};
961 let mut tree = themed_tree();
962 let inner = tree.add(RectWidget::new());
963 let expand = tree.add(Expand::new().child(inner));
964 let dt = tree.add(DropTarget::new().child(expand));
965 let pad = tree.add(Padding::uniform(16.0).child(dt));
966 let _z = tree.add(ZStack::new().child(RectWidget::new()).child(pad));
967 tree.layout(SizeProposal::exact(800.0, 600.0));
968 let b = tree.bounds(inner);
969 assert!(
970 b.width > 700.0 && b.height > 500.0,
971 "flexible child collapsed inside DropTarget: {b:?}"
972 );
973 }
974
975 #[test]
979 fn border_overlay_does_not_block_taps_to_content() {
980 use teksilo_core::event::PointerButton;
981 use teksilo_core::widget_builder::WidgetBuilder;
982 let tapped = Rc::new(Cell::new(false));
983 let t = tapped.clone();
984 let mut tree = themed_tree();
985 let inner = tree.add(RectWidget::new().on_tap(move |_e, _ctx| t.set(true)));
986 tree.add(DropTarget::new().child(inner));
987 tree.layout(SizeProposal::exact(200.0, 100.0));
988 let center = tree.bounds(inner).center();
989 tree.pointer_down_button(center, PointerButton::Primary);
990 tree.pointer_up_button(center, PointerButton::Primary);
991 assert!(
992 tapped.get(),
993 "the DropTarget border overlay must not eat taps meant for the wrapped content"
994 );
995 }
996
997 #[test]
999 fn external_file_accepted_fires_on_drop() {
1000 let mut tree = themed_tree();
1001 let dropped = Rc::new(Cell::new(false));
1002 let d = dropped.clone();
1003 tree.add(
1004 DropTarget::new()
1005 .child(RectWidget::new())
1006 .accept_external_files()
1007 .on_drop(move |_payload, _pos, _ctx| {
1008 d.set(true);
1009 true
1010 }),
1011 );
1012 tree.layout(SizeProposal::exact(400.0, 300.0));
1013
1014 let mut noop = NoopWindowOps;
1015 let data = ExternalDropData {
1016 files: vec![PathBuf::from("/tmp/photo.png")],
1017 ..Default::default()
1018 };
1019 let p = Point::new(200.0, 150.0);
1020 tree.begin_external_drag(p, data.clone(), &mut noop);
1021 tree.end_external_drag(p, data, &mut noop);
1022
1023 assert!(dropped.get(), "accepted file drop should fire on_drop");
1024 }
1025
1026 #[test]
1030 fn internal_typed_drop_extracts_value() {
1031 #[derive(Debug, Clone, PartialEq)]
1032 struct ProjectRef(u32);
1033
1034 #[derive(Debug)]
1036 struct TypedDragSource;
1037 impl Widget for TypedDragSource {
1038 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1039 let self_id = ctx.self_id();
1040 let hs = HandlerSet::new().on_drag(move |phase, ctx| {
1041 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
1042 ctx.start_drag(self_id, DragPayload::typed(ProjectRef(7)));
1043 }
1044 });
1045 ctx.apply_self_handlers(hs);
1046 Vec::new()
1047 }
1048 fn layout_response(
1049 &self,
1050 _proposal: SizeProposal,
1051 _ctx: &LayoutContext,
1052 ) -> LayoutResponse {
1053 Size::new(100.0, 80.0).into()
1054 }
1055 }
1056
1057 let mut tree = themed_tree();
1058 let got: Rc<RefCell<Option<ProjectRef>>> = Rc::new(RefCell::new(None));
1059 let g = got.clone();
1060 let target = DropTarget::new()
1061 .child(Fixed(100.0, 80.0))
1062 .on_drop_typed::<ProjectRef>(move |project, _pos, _ctx| {
1063 *g.borrow_mut() = Some(project);
1064 true
1065 });
1066 let source_id = tree.add(TypedDragSource);
1067 let target_id = tree.add(target);
1068 let es = tree.add(crate::primitives::Expand::new().flex(1.0).child(source_id));
1069 let et = tree.add(crate::primitives::Expand::new().flex(1.0).child(target_id));
1070 tree.add(crate::primitives::HStack::new().child(es).child(et));
1071 tree.layout(SizeProposal::exact(400.0, 200.0));
1072
1073 let from = tree.bounds(source_id).center();
1074 let to = tree.bounds(target_id).center();
1075 tree.drag(from, to);
1076
1077 assert_eq!(
1078 *got.borrow(),
1079 Some(ProjectRef(7)),
1080 "internal typed drop must extract and deliver the typed value",
1081 );
1082 }
1083
1084 #[test]
1087 fn internal_typed_drop_rejects_other_type() {
1088 #[derive(Debug, Clone)]
1089 struct ProjectRef(u32);
1090 #[derive(Debug, Clone)]
1091 struct OtherRef(u32);
1092
1093 #[derive(Debug)]
1094 struct OtherSource;
1095 impl Widget for OtherSource {
1096 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1097 let self_id = ctx.self_id();
1098 let hs = HandlerSet::new().on_drag(move |phase, ctx| {
1099 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
1100 ctx.start_drag(self_id, DragPayload::typed(OtherRef(1)));
1101 }
1102 });
1103 ctx.apply_self_handlers(hs);
1104 Vec::new()
1105 }
1106 fn layout_response(
1107 &self,
1108 _proposal: SizeProposal,
1109 _ctx: &LayoutContext,
1110 ) -> LayoutResponse {
1111 Size::new(100.0, 80.0).into()
1112 }
1113 }
1114
1115 let mut tree = themed_tree();
1116 let fired = Rc::new(Cell::new(false));
1117 let f = fired.clone();
1118 let target = DropTarget::new()
1119 .child(Fixed(100.0, 80.0))
1120 .on_drop_typed::<ProjectRef>(move |_p, _pos, _ctx| {
1121 f.set(true);
1122 true
1123 });
1124 let source_id = tree.add(OtherSource);
1125 let target_id = tree.add(target);
1126 let es = tree.add(crate::primitives::Expand::new().flex(1.0).child(source_id));
1127 let et = tree.add(crate::primitives::Expand::new().flex(1.0).child(target_id));
1128 tree.add(crate::primitives::HStack::new().child(es).child(et));
1129 tree.layout(SizeProposal::exact(400.0, 200.0));
1130
1131 let from = tree.bounds(source_id).center();
1132 let to = tree.bounds(target_id).center();
1133 tree.drag(from, to);
1134
1135 assert!(!fired.get(), "a payload of the wrong type must be rejected");
1136 }
1137
1138 #[test]
1141 fn extension_filter_rejects_wrong_type() {
1142 let mut tree = themed_tree();
1143 let dropped = Rc::new(Cell::new(false));
1144 let d = dropped.clone();
1145 tree.add(
1146 DropTarget::new()
1147 .child(RectWidget::new())
1148 .accept_external_extensions(["png"])
1149 .on_drop(move |_payload, _pos, _ctx| {
1150 d.set(true);
1151 true
1152 }),
1153 );
1154 tree.layout(SizeProposal::exact(400.0, 300.0));
1155
1156 let mut noop = NoopWindowOps;
1157 let data = ExternalDropData {
1158 files: vec![PathBuf::from("/tmp/notes.txt")],
1159 ..Default::default()
1160 };
1161 let p = Point::new(200.0, 150.0);
1162 tree.begin_external_drag(p, data.clone(), &mut noop);
1163 tree.end_external_drag(p, data, &mut noop);
1164
1165 assert!(!dropped.get(), "non-png drop must be rejected");
1166 }
1167
1168 #[test]
1171 fn is_targeted_tracks_accepted_hover() {
1172 let mut tree = themed_tree();
1173 let targeted = Signal::new(false);
1174 tree.add(
1175 DropTarget::new()
1176 .child(RectWidget::new())
1177 .accept_external_files()
1178 .targeted_signal(targeted.clone())
1179 .on_drop(|_p, _pos, _ctx| true),
1180 );
1181 tree.layout(SizeProposal::exact(400.0, 300.0));
1182
1183 let mut noop = NoopWindowOps;
1184 let data = ExternalDropData {
1185 files: vec![PathBuf::from("/tmp/a.png")],
1186 ..Default::default()
1187 };
1188 let p = Point::new(200.0, 150.0);
1189 tree.begin_external_drag(p, data.clone(), &mut noop);
1190 assert!(targeted.get(), "accepted hover sets is_targeted true");
1191 tree.end_external_drag(p, data, &mut noop);
1192 assert!(!targeted.get(), "drop/leave resets is_targeted");
1193 }
1194
1195 #[test]
1198 fn drag_state_reports_reject() {
1199 let mut tree = themed_tree();
1200 let state = Signal::new(DropTargetDragState::Idle);
1201 tree.add(
1202 DropTarget::new()
1203 .child(RectWidget::new())
1204 .accept_external_extensions(["png"])
1205 .drag_state_signal(state.clone())
1206 .on_drop(|_p, _pos, _ctx| true),
1207 );
1208 tree.layout(SizeProposal::exact(400.0, 300.0));
1209
1210 let mut noop = NoopWindowOps;
1211 let data = ExternalDropData {
1212 files: vec![PathBuf::from("/tmp/notes.txt")],
1213 ..Default::default()
1214 };
1215 let p = Point::new(200.0, 150.0);
1216 tree.begin_external_drag(p, data, &mut noop);
1217 assert_eq!(state.get(), DropTargetDragState::HoverReject);
1218 }
1219
1220 #[test]
1223 fn hint_paints_only_on_accepted_hover() {
1224 let mut tree = themed_tree();
1225 tree.add(
1226 DropTarget::new()
1227 .child(RectWidget::new())
1228 .hint(Marker)
1229 .accept_external_files()
1230 .on_drop(|_p, _pos, _ctx| true),
1231 );
1232 tree.layout(SizeProposal::exact(400.0, 300.0));
1233
1234 let red = teksilo_tokens::Color::RED.to_array();
1235 let frame = tree.render();
1236 assert!(
1237 !frame.shapes.iter().any(|s| s.color == red),
1238 "hint must be hidden at rest"
1239 );
1240
1241 let mut noop = NoopWindowOps;
1242 let data = ExternalDropData {
1243 files: vec![PathBuf::from("/tmp/a.png")],
1244 ..Default::default()
1245 };
1246 let p = Point::new(200.0, 150.0);
1247 tree.begin_external_drag(p, data, &mut noop);
1248 tree.layout(SizeProposal::exact(400.0, 300.0));
1249 let frame = tree.render();
1250 assert!(
1251 frame.shapes.iter().any(|s| s.color == red),
1252 "hint must paint while an accepted drag hovers"
1253 );
1254 }
1255
1256 #[test]
1259 fn builds_with_hint_and_prominent_variant() {
1260 let mut tree = themed_tree();
1261 let target = tree.add(
1262 DropTarget::new()
1263 .child(Fixed(160.0, 90.0))
1264 .hint(crate::primitives::TextWidget::new(lit!("Drop here")))
1265 .variant(DropTargetVariant::Prominent)
1266 .accept_any()
1267 .on_drop(|_p, _pos, _ctx| true),
1268 );
1269 tree.layout(SizeProposal::exact(160.0, 90.0));
1270 let _ = tree.render();
1271 let b = tree.bounds(target);
1272 assert!(b.width > 0.0 && b.height > 0.0);
1273 }
1274
1275 fn png_drop(tree: &mut WidgetTree, p: Point) {
1278 let mut noop = NoopWindowOps;
1279 let data = ExternalDropData {
1280 files: vec![PathBuf::from("/tmp/a.png")],
1281 ..Default::default()
1282 };
1283 tree.begin_external_drag(p, data.clone(), &mut noop);
1284 tree.end_external_drag(p, data, &mut noop);
1285 }
1286
1287 #[test]
1289 fn region_drop_reports_leading() {
1290 let mut tree = themed_tree();
1291 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1292 let g = got.clone();
1293 tree.add(
1294 DropTarget::new()
1295 .child(RectWidget::new())
1296 .region(DropRegion::Center, |z| z)
1297 .region(DropRegion::Leading, |z| z)
1298 .accept_external_files()
1299 .on_region_drop(move |region, _p, _pos, _ctx| {
1300 *g.borrow_mut() = Some(region);
1301 true
1302 }),
1303 );
1304 tree.layout(SizeProposal::exact(400.0, 300.0));
1305 png_drop(&mut tree, Point::new(20.0, 150.0));
1307 assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1308 }
1309
1310 #[test]
1312 fn region_drop_reports_center_in_middle() {
1313 let mut tree = themed_tree();
1314 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1315 let g = got.clone();
1316 tree.add(
1317 DropTarget::new()
1318 .child(RectWidget::new())
1319 .region(DropRegion::Center, |z| z)
1320 .region(DropRegion::Leading, |z| z)
1321 .region(DropRegion::Trailing, |z| z)
1322 .accept_external_files()
1323 .on_region_drop(move |region, _p, _pos, _ctx| {
1324 *g.borrow_mut() = Some(region);
1325 true
1326 }),
1327 );
1328 tree.layout(SizeProposal::exact(400.0, 300.0));
1329 png_drop(&mut tree, Point::new(200.0, 150.0));
1330 assert_eq!(*got.borrow(), Some(DropRegion::Center));
1331 }
1332
1333 #[test]
1337 fn zone_size_factor_widens_side_zones() {
1338 let mut tree = themed_tree();
1339 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1340 let g = got.clone();
1341 tree.add(
1342 DropTarget::new()
1343 .child(RectWidget::new())
1344 .zone_size_factor(0.5)
1345 .region(DropRegion::Center, |z| z)
1346 .region(DropRegion::Leading, |z| z)
1347 .accept_external_files()
1348 .on_region_drop(move |region, _p, _pos, _ctx| {
1349 *g.borrow_mut() = Some(region);
1350 true
1351 }),
1352 );
1353 tree.layout(SizeProposal::exact(400.0, 300.0));
1354 png_drop(&mut tree, Point::new(150.0, 150.0));
1356 assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1357 }
1358
1359 #[test]
1362 fn center_only_default_uses_plain_on_drop() {
1363 let mut tree = themed_tree();
1364 let dropped = Rc::new(Cell::new(false));
1365 let d = dropped.clone();
1366 tree.add(
1367 DropTarget::new()
1368 .child(RectWidget::new())
1369 .accept_external_files()
1370 .on_drop(move |_p, _pos, _ctx| {
1371 d.set(true);
1372 true
1373 }),
1374 );
1375 tree.layout(SizeProposal::exact(400.0, 300.0));
1376 png_drop(&mut tree, Point::new(20.0, 150.0));
1377 assert!(
1378 dropped.get(),
1379 "center-only default must route to plain on_drop"
1380 );
1381 }
1382
1383 #[test]
1385 fn active_region_signal_tracks_and_resets() {
1386 let mut tree = themed_tree();
1387 let region = Signal::new(None);
1388 tree.add(
1389 DropTarget::new()
1390 .child(RectWidget::new())
1391 .region(DropRegion::Center, |z| z)
1392 .region(DropRegion::Leading, |z| z)
1393 .accept_external_files()
1394 .active_region_signal(region.clone())
1395 .on_drop(|_p, _pos, _ctx| true),
1396 );
1397 tree.layout(SizeProposal::exact(400.0, 300.0));
1398
1399 let mut noop = NoopWindowOps;
1400 let data = ExternalDropData {
1401 files: vec![PathBuf::from("/tmp/a.png")],
1402 ..Default::default()
1403 };
1404 let p = Point::new(20.0, 150.0);
1405 tree.begin_external_drag(p, data.clone(), &mut noop);
1406 assert_eq!(region.get(), Some(DropRegion::Leading));
1407 tree.end_external_drag(p, data, &mut noop);
1408 assert_eq!(region.get(), None, "leave resets the active region");
1409 }
1410
1411 #[test]
1414 fn per_region_hint_paints_only_for_its_zone() {
1415 let mut tree = themed_tree();
1416 tree.add(
1417 DropTarget::new()
1418 .child(RectWidget::new())
1419 .region(DropRegion::Center, |z| z)
1420 .region(DropRegion::Leading, |z| z.hint(Marker))
1421 .accept_external_files()
1422 .on_drop(|_p, _pos, _ctx| true),
1423 );
1424 tree.layout(SizeProposal::exact(400.0, 300.0));
1425 let red = teksilo_tokens::Color::RED.to_array();
1426
1427 let mut noop = NoopWindowOps;
1428 let data = ExternalDropData {
1429 files: vec![PathBuf::from("/tmp/a.png")],
1430 ..Default::default()
1431 };
1432
1433 let center = Point::new(200.0, 150.0);
1435 tree.begin_external_drag(center, data.clone(), &mut noop);
1436 tree.layout(SizeProposal::exact(400.0, 300.0));
1437 assert!(
1438 !tree.render().shapes.iter().any(|s| s.color == red),
1439 "Leading hint must not paint while hovering the centre"
1440 );
1441 tree.end_external_drag(center, data.clone(), &mut noop);
1442
1443 let lead = Point::new(20.0, 150.0);
1445 tree.begin_external_drag(lead, data.clone(), &mut noop);
1446 tree.layout(SizeProposal::exact(400.0, 300.0));
1447 assert!(
1448 tree.render().shapes.iter().any(|s| s.color == red),
1449 "Leading hint must paint while hovering the leading zone"
1450 );
1451 tree.end_external_drag(lead, data, &mut noop);
1452 }
1453
1454 #[test]
1459 fn side_only_dead_middle_rejects_drop_and_hover() {
1460 let mut tree = themed_tree();
1461 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1462 let g = got.clone();
1463 let region = Signal::new(None);
1464 let targeted = Signal::new(false);
1465 tree.add(
1466 DropTarget::new()
1467 .child(RectWidget::new())
1468 .zone_size_factor(0.2)
1469 .region(DropRegion::Leading, |z| z)
1470 .region(DropRegion::Trailing, |z| z)
1471 .accept_external_files()
1472 .active_region_signal(region.clone())
1473 .targeted_signal(targeted.clone())
1474 .on_region_drop(move |r, _p, _pos, _ctx| {
1475 *g.borrow_mut() = Some(r);
1476 true
1477 }),
1478 );
1479 tree.layout(SizeProposal::exact(400.0, 300.0));
1480
1481 let mut noop = NoopWindowOps;
1482 let data = ExternalDropData {
1483 files: vec![PathBuf::from("/tmp/a.png")],
1484 ..Default::default()
1485 };
1486 tree.begin_external_drag(Point::new(20.0, 150.0), data.clone(), &mut noop);
1488 assert_eq!(region.get(), Some(DropRegion::Leading));
1489 assert!(targeted.get(), "hovering an enabled zone engages");
1490 tree.update_external_drag(Point::new(200.0, 150.0), &mut noop);
1492 assert_eq!(region.get(), None, "dead middle reports no zone");
1493 assert!(!targeted.get(), "dead middle must not engage this target");
1494 tree.end_external_drag(Point::new(200.0, 150.0), data, &mut noop);
1496 assert_eq!(
1497 *got.borrow(),
1498 None,
1499 "a dead-middle drop must be rejected, not fabricated as Center"
1500 );
1501 }
1502
1503 #[test]
1506 fn top_and_bottom_zones_route() {
1507 for (y, expected) in [(10.0_f32, DropRegion::Top), (290.0_f32, DropRegion::Bottom)] {
1508 let mut tree = themed_tree();
1509 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1510 let g = got.clone();
1511 tree.add(
1512 DropTarget::new()
1513 .child(RectWidget::new())
1514 .region(DropRegion::Top, |z| z)
1515 .region(DropRegion::Bottom, |z| z)
1516 .region(DropRegion::Center, |z| z)
1517 .accept_external_files()
1518 .on_region_drop(move |r, _p, _pos, _ctx| {
1519 *g.borrow_mut() = Some(r);
1520 true
1521 }),
1522 );
1523 tree.layout(SizeProposal::exact(400.0, 300.0));
1524 png_drop(&mut tree, Point::new(200.0, y));
1526 assert_eq!(*got.borrow(), Some(expected));
1527 }
1528 }
1529
1530 #[test]
1533 fn rejected_hover_reports_no_region() {
1534 let mut tree = themed_tree();
1535 let region = Signal::new(None);
1536 let state = Signal::new(DropTargetDragState::Idle);
1537 tree.add(
1538 DropTarget::new()
1539 .child(RectWidget::new())
1540 .region(DropRegion::Leading, |z| z)
1541 .region(DropRegion::Center, |z| z)
1542 .accept_external_extensions(["png"])
1543 .active_region_signal(region.clone())
1544 .drag_state_signal(state.clone())
1545 .on_drop(|_p, _pos, _ctx| true),
1546 );
1547 tree.layout(SizeProposal::exact(400.0, 300.0));
1548
1549 let mut noop = NoopWindowOps;
1550 let data = ExternalDropData {
1552 files: vec![PathBuf::from("/tmp/notes.txt")],
1553 ..Default::default()
1554 };
1555 tree.begin_external_drag(Point::new(20.0, 150.0), data, &mut noop);
1556 assert_eq!(state.get(), DropTargetDragState::HoverReject);
1557 assert_eq!(
1558 region.get(),
1559 None,
1560 "a rejected hover must report no zone even inside a would-be strip"
1561 );
1562 }
1563
1564 #[test]
1568 fn reactive_zone_enabled_gates_hit_testing() {
1569 let mut tree = themed_tree();
1570 let leading_on = Signal::new(true);
1571 let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1572 let g = got.clone();
1573 tree.add(
1574 DropTarget::new()
1575 .child(RectWidget::new())
1576 .zone_size_factor(0.2)
1577 .region(DropRegion::Leading, |z| z.enabled(leading_on.clone()))
1578 .region(DropRegion::Center, |z| z)
1579 .accept_external_files()
1580 .on_region_drop(move |r, _p, _pos, _ctx| {
1581 *g.borrow_mut() = Some(r);
1582 true
1583 }),
1584 );
1585 tree.layout(SizeProposal::exact(400.0, 300.0));
1586 png_drop(&mut tree, Point::new(20.0, 150.0));
1588 assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1589 leading_on.set(false);
1591 *got.borrow_mut() = None;
1592 png_drop(&mut tree, Point::new(20.0, 150.0));
1593 assert_eq!(
1594 *got.borrow(),
1595 Some(DropRegion::Center),
1596 "a live-disabled zone falls through to the next enabled zone"
1597 );
1598 }
1599}