Skip to main content

gpui_rhai/
drag_drop.rs

1//! Host-domain typed application drag/drop primitives.
2
3use std::cell::RefCell;
4use std::collections::{BTreeMap, BTreeSet};
5use std::rc::Rc;
6
7use gpui::{
8    AnyElement, App, AppContext, Background, Bounds, Context, DispatchPhase, Edges, Element,
9    ElementId, Entity, FocusHandle, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
10    InteractiveElement, IntoElement, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent,
11    PaintQuad, ParentElement, Pixels, Render, Style, Styled, Window, div, px, relative, rgba, size,
12};
13
14use crate::{
15    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
16    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps,
17    PrimitiveTheme, Rgba8, UiValue, ValueSchema,
18};
19
20#[derive(Clone)]
21struct DragSourceConfig {
22    id: String,
23    source_id: String,
24    payload_type: String,
25    payload: UiValue,
26    operation: crate::interaction::DragOperation,
27    threshold: f64,
28    keyboard_target: Option<String>,
29    disabled: bool,
30    focus: Option<FocusHandle>,
31    accent: Rgba8,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35struct DragSourceFingerprint {
36    source_id: String,
37    payload_type: String,
38    payload: UiValue,
39    operation: crate::interaction::DragOperation,
40    disabled: bool,
41}
42
43#[derive(Clone)]
44struct DragSourceState(Rc<RefCell<DragSourceFingerprint>>);
45
46impl DragSourceState {
47    fn new(config: &DragSourceConfig) -> Self {
48        Self(Rc::new(RefCell::new(source_fingerprint(config))))
49    }
50}
51
52fn source_fingerprint(config: &DragSourceConfig) -> DragSourceFingerprint {
53    DragSourceFingerprint {
54        source_id: config.source_id.clone(),
55        payload_type: config.payload_type.clone(),
56        payload: config.payload.clone(),
57        operation: config.operation,
58        disabled: config.disabled,
59    }
60}
61
62struct SourcePrepaint {
63    hitbox: Hitbox,
64}
65
66struct DragSourceElement {
67    config: DragSourceConfig,
68    context: PrimitiveContext,
69}
70
71impl IntoElement for DragSourceElement {
72    type Element = Self;
73
74    fn into_element(self) -> Self::Element {
75        self
76    }
77}
78
79impl Element for DragSourceElement {
80    type RequestLayoutState = ();
81    type PrepaintState = SourcePrepaint;
82
83    fn id(&self) -> Option<ElementId> {
84        Some(ElementId::Name(self.config.id.clone().into()))
85    }
86
87    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
88        None
89    }
90
91    fn request_layout(
92        &mut self,
93        _: Option<&GlobalElementId>,
94        _: Option<&InspectorElementId>,
95        window: &mut Window,
96        cx: &mut App,
97    ) -> (LayoutId, ()) {
98        (
99            window.request_layout(
100                Style {
101                    size: size(relative(1.0).into(), relative(1.0).into()),
102                    ..Style::default()
103                },
104                None,
105                cx,
106            ),
107            (),
108        )
109    }
110
111    fn prepaint(
112        &mut self,
113        _: Option<&GlobalElementId>,
114        _: Option<&InspectorElementId>,
115        bounds: Bounds<Pixels>,
116        (): &mut (),
117        window: &mut Window,
118        cx: &mut App,
119    ) -> SourcePrepaint {
120        let state = window
121            .use_state(cx, |_, _| DragSourceState::new(&self.config))
122            .read(cx)
123            .clone();
124        let changed = {
125            let next = source_fingerprint(&self.config);
126            let mut fingerprint = state.0.borrow_mut();
127            let changed = *fingerprint != next;
128            *fingerprint = next;
129            changed
130        };
131        let owner = self.context.interaction_owner(&self.config.id);
132        if changed {
133            self.context.cancel_interaction(&owner, window, cx);
134        }
135        SourcePrepaint {
136            hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
137        }
138    }
139
140    fn paint(
141        &mut self,
142        _: Option<&GlobalElementId>,
143        _: Option<&InspectorElementId>,
144        bounds: Bounds<Pixels>,
145        (): &mut (),
146        prepaint: &mut SourcePrepaint,
147        window: &mut Window,
148        _: &mut App,
149    ) {
150        let owner = self.context.interaction_owner(&self.config.id);
151        self.context.present_interaction(owner.clone());
152        if self.context.app_drag_source_active(&owner) {
153            paint_feedback(bounds, self.config.accent, 0x16, window);
154        }
155        register_source_pointer(prepaint, &self.config, &self.context, window);
156    }
157}
158
159fn register_source_pointer(
160    prepaint: &SourcePrepaint,
161    config: &DragSourceConfig,
162    context: &PrimitiveContext,
163    window: &mut Window,
164) {
165    let hitbox = prepaint.hitbox.clone();
166    let config = config.clone();
167    let context = context.clone();
168    let view = window.current_view();
169    window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
170        if phase != DispatchPhase::Bubble
171            || event.button != MouseButton::Left
172            || config.disabled
173            || !hitbox.is_hovered(window)
174        {
175            return;
176        }
177        if let Some(focus) = config.focus.as_ref() {
178            focus.focus(window, cx);
179        }
180        let owner = context.interaction_owner(&config.id);
181        let spec = drag_spec(&config, owner.clone(), view);
182        let finish_context = context.clone();
183        context.begin_application_drag(
184            spec,
185            event.position,
186            config.threshold,
187            move |result, cancelled, window, cx| {
188                finish_context.propose("drag_end", drag_end_value(&result, cancelled), window, cx);
189            },
190            window,
191            cx,
192        );
193        cx.stop_propagation();
194    });
195}
196
197fn drag_spec(
198    config: &DragSourceConfig,
199    owner: crate::interaction::InteractionOwner,
200    notify: gpui::EntityId,
201) -> crate::interaction::ApplicationDragSpec {
202    crate::interaction::ApplicationDragSpec::new(
203        owner,
204        config.source_id.clone(),
205        config.payload_type.clone(),
206        config.payload.clone(),
207        config.operation,
208        notify,
209    )
210}
211
212#[derive(Clone)]
213struct DropZoneConfig {
214    id: String,
215    target_id: String,
216    payload_types: BTreeSet<String>,
217    operations: BTreeSet<crate::interaction::DragOperation>,
218    priority: i64,
219    disabled: bool,
220    accent: Rgba8,
221    danger: Rgba8,
222}
223
224struct ZonePrepaint {
225    bounds: Bounds<Pixels>,
226    hitbox: Hitbox,
227}
228
229struct DropZoneElement {
230    config: DropZoneConfig,
231    context: PrimitiveContext,
232}
233
234impl IntoElement for DropZoneElement {
235    type Element = Self;
236
237    fn into_element(self) -> Self::Element {
238        self
239    }
240}
241
242impl Element for DropZoneElement {
243    type RequestLayoutState = ();
244    type PrepaintState = ZonePrepaint;
245
246    fn id(&self) -> Option<ElementId> {
247        Some(ElementId::Name(self.config.id.clone().into()))
248    }
249
250    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
251        None
252    }
253
254    fn request_layout(
255        &mut self,
256        _: Option<&GlobalElementId>,
257        _: Option<&InspectorElementId>,
258        window: &mut Window,
259        cx: &mut App,
260    ) -> (LayoutId, ()) {
261        (
262            window.request_layout(
263                Style {
264                    size: size(relative(1.0).into(), relative(1.0).into()),
265                    ..Style::default()
266                },
267                None,
268                cx,
269            ),
270            (),
271        )
272    }
273
274    fn prepaint(
275        &mut self,
276        _: Option<&GlobalElementId>,
277        _: Option<&InspectorElementId>,
278        bounds: Bounds<Pixels>,
279        (): &mut (),
280        window: &mut Window,
281        _: &mut App,
282    ) -> ZonePrepaint {
283        let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
284        ZonePrepaint { bounds, hitbox }
285    }
286
287    fn paint(
288        &mut self,
289        _: Option<&GlobalElementId>,
290        _: Option<&InspectorElementId>,
291        _: Bounds<Pixels>,
292        (): &mut (),
293        prepaint: &mut ZonePrepaint,
294        window: &mut Window,
295        _: &mut App,
296    ) {
297        let owner = self.context.interaction_owner(&self.config.id);
298        if self.config.disabled {
299            return;
300        }
301        let Ok(bounds) = crate::GeometryBounds::new(
302            f64::from(prepaint.bounds.origin.x),
303            f64::from(prepaint.bounds.origin.y),
304            f64::from(prepaint.bounds.size.width),
305            f64::from(prepaint.bounds.size.height),
306        ) else {
307            return;
308        };
309        let target_context = self.context.clone();
310        let target_id = self.config.target_id.clone();
311        self.context
312            .register_drop_target(crate::interaction::DropTargetRegistration::new(
313                owner.clone(),
314                self.config.target_id.clone(),
315                bounds,
316                self.config.payload_types.clone(),
317                self.config.operations.clone(),
318                self.config.priority,
319                prepaint.hitbox.clone(),
320                self.context.ancestor_scroll_handles(),
321                window.current_view(),
322                move |drag, position, window, cx| {
323                    target_context.propose(
324                        "drop",
325                        drop_value(drag, &target_id, position),
326                        window,
327                        cx,
328                    );
329                },
330            ));
331        match self.context.drop_target_state(&owner) {
332            crate::interaction::DropTargetState::Active => {
333                paint_feedback(prepaint.bounds, self.config.accent, 0x28, window);
334            }
335            crate::interaction::DropTargetState::Eligible => {
336                paint_feedback(prepaint.bounds, self.config.accent, 0x0e, window);
337            }
338            crate::interaction::DropTargetState::Invalid => {
339                paint_feedback(prepaint.bounds, self.config.danger, 0x12, window);
340            }
341            crate::interaction::DropTargetState::Idle => {}
342        }
343    }
344}
345
346fn paint_feedback(bounds: Bounds<Pixels>, color: Rgba8, alpha: u8, window: &mut Window) {
347    let translucent = Rgba8::from_rgba_hex((color.as_rgba_hex() & 0xffff_ff00) | u32::from(alpha));
348    window.paint_quad(PaintQuad {
349        bounds,
350        corner_radii: px(0.0).into(),
351        background: Background::from(rgba(translucent.as_rgba_hex())),
352        border_widths: Edges::all(px(1.0)),
353        border_color: rgba(color.as_rgba_hex()).into(),
354        border_style: gpui::BorderStyle::default(),
355    });
356}
357
358fn drop_value(
359    drag: &crate::interaction::ApplicationDragSpec,
360    target_id: &str,
361    position: gpui::Point<Pixels>,
362) -> UiValue {
363    UiValue::Map(BTreeMap::from([
364        (
365            "source_id".to_owned(),
366            UiValue::String(drag.source_id().to_owned()),
367        ),
368        (
369            "target_id".to_owned(),
370            UiValue::String(target_id.to_owned()),
371        ),
372        (
373            "payload_type".to_owned(),
374            UiValue::String(drag.payload_type().to_owned()),
375        ),
376        ("payload".to_owned(), drag.payload().clone()),
377        (
378            "operation".to_owned(),
379            UiValue::String(drag.operation().as_str().to_owned()),
380        ),
381        ("x".to_owned(), UiValue::Float(f64::from(position.x))),
382        ("y".to_owned(), UiValue::Float(f64::from(position.y))),
383    ]))
384}
385
386fn drag_end_value(result: &crate::interaction::ApplicationDropResult, cancelled: bool) -> UiValue {
387    UiValue::Map(BTreeMap::from([
388        ("accepted".to_owned(), UiValue::Bool(result.accepted)),
389        (
390            "target_id".to_owned(),
391            result
392                .target_id
393                .as_ref()
394                .map_or(UiValue::Null, |target| UiValue::String(target.clone())),
395        ),
396        (
397            "operation".to_owned(),
398            UiValue::String(result.operation.as_str().to_owned()),
399        ),
400        ("cancelled".to_owned(), UiValue::Bool(cancelled)),
401    ]))
402}
403
404struct DragSourceEntity {
405    focus: FocusHandle,
406    config: DragSourceConfig,
407    context: PrimitiveContext,
408}
409
410impl DragSourceEntity {
411    fn new(
412        mut config: DragSourceConfig,
413        context: PrimitiveContext,
414        cx: &mut Context<Self>,
415    ) -> Self {
416        let focus = config.focus.clone().unwrap_or_else(|| cx.focus_handle());
417        config.focus = Some(focus.clone());
418        Self {
419            focus,
420            config,
421            context,
422        }
423    }
424
425    fn update(&mut self, mut config: DragSourceConfig, context: PrimitiveContext) {
426        config.focus = Some(self.focus.clone());
427        self.config = config;
428        self.context = context;
429    }
430
431    fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
432        if self.config.disabled || !matches!(event.keystroke.key.as_str(), "enter" | "space") {
433            return;
434        }
435        let Some(target) = self.config.keyboard_target.as_deref() else {
436            return;
437        };
438        let owner = self.context.interaction_owner(&self.config.id);
439        let spec = drag_spec(&self.config, owner, cx.entity_id());
440        let result = self
441            .context
442            .perform_keyboard_drop(&spec, target, window, cx);
443        self.context
444            .propose("drag_end", drag_end_value(&result, false), window, cx);
445        cx.stop_propagation();
446    }
447}
448
449impl Render for DragSourceEntity {
450    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
451        div()
452            .size_full()
453            .track_focus(&self.focus.clone().tab_stop(!self.config.disabled))
454            .on_key_down(cx.listener(Self::key_down))
455            .child(DragSourceElement {
456                config: self.config.clone(),
457                context: self.context.clone(),
458            })
459    }
460}
461
462#[derive(Default)]
463pub struct DragSourcePrimitiveHandler {
464    instances: BTreeMap<PrimitiveInstanceId, Entity<DragSourceEntity>>,
465}
466
467impl PrimitiveHandler for DragSourcePrimitiveHandler {
468    fn uses_primary_focus(&self) -> bool {
469        true
470    }
471
472    fn render(
473        &mut self,
474        instance: &PrimitiveInstance,
475        context: &PrimitiveContext,
476        theme: &PrimitiveTheme,
477        _: &mut Window,
478        cx: &mut App,
479    ) -> Result<AnyElement, String> {
480        let id = instance
481            .id
482            .clone()
483            .ok_or_else(|| "DragSourcePrimitive requires a stable key".to_owned())?;
484        let config = parse_source(
485            &instance.node.props,
486            instance.focus_handle().cloned(),
487            theme,
488        )?;
489        let entity = if let Some(entity) = self.instances.get(&id) {
490            entity.clone()
491        } else {
492            let entity = cx.new(|cx| DragSourceEntity::new(config.clone(), context.clone(), cx));
493            self.instances.insert(id.clone(), entity.clone());
494            entity
495        };
496        entity.update(cx, |source, _| source.update(config, context.clone()));
497        Ok(entity.into_any_element())
498    }
499
500    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
501        self.instances.remove(instance);
502    }
503}
504
505#[derive(Default)]
506pub struct DropZonePrimitiveHandler;
507
508impl PrimitiveHandler for DropZonePrimitiveHandler {
509    fn render(
510        &mut self,
511        instance: &PrimitiveInstance,
512        context: &PrimitiveContext,
513        theme: &PrimitiveTheme,
514        _: &mut Window,
515        _: &mut App,
516    ) -> Result<AnyElement, String> {
517        Ok(DropZoneElement {
518            config: parse_target(&instance.node.props, theme)?,
519            context: context.clone(),
520        }
521        .into_any_element())
522    }
523}
524
525fn parse_source(
526    props: &PrimitiveProps,
527    focus: Option<FocusHandle>,
528    theme: &PrimitiveTheme,
529) -> Result<DragSourceConfig, String> {
530    let source_id = required_safe_string(props, "source_id")?;
531    let payload_type = required_safe_string(props, "payload_type")?;
532    let operation = props
533        .string("operation")
534        .and_then(crate::interaction::DragOperation::parse)
535        .ok_or_else(|| "drag source operation must be copy or move".to_owned())?;
536    let threshold = props.number("threshold").unwrap_or(4.0);
537    if !threshold.is_finite() || !(0.0..=64.0).contains(&threshold) {
538        return Err("drag source threshold must be finite and in [0,64]".to_owned());
539    }
540    let payload = props
541        .data("payload")
542        .cloned()
543        .ok_or_else(|| "drag source requires payload".to_owned())?;
544    Ok(DragSourceConfig {
545        id: format!("gpui-rhai-drag-source:{source_id}"),
546        source_id,
547        payload_type,
548        payload,
549        operation,
550        threshold,
551        keyboard_target: props.string("keyboard_target").map(str::to_owned),
552        disabled: props.boolean("disabled").unwrap_or(false),
553        focus,
554        accent: theme
555            .color("accent")
556            .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
557    })
558}
559
560fn parse_target(props: &PrimitiveProps, theme: &PrimitiveTheme) -> Result<DropZoneConfig, String> {
561    let target_id = required_safe_string(props, "target_id")?;
562    let payload_types = string_set(props, "payload_types")?;
563    let operations = operation_set(props, "operations")?;
564    if payload_types.is_empty() || operations.is_empty() {
565        return Err("drop zone requires at least one payload type and operation".to_owned());
566    }
567    Ok(DropZoneConfig {
568        id: format!("gpui-rhai-drop-zone:{target_id}"),
569        target_id,
570        payload_types,
571        operations,
572        priority: props.integer("priority").unwrap_or(0),
573        disabled: props.boolean("disabled").unwrap_or(false),
574        accent: theme
575            .color("accent")
576            .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
577        danger: theme
578            .color("danger")
579            .unwrap_or(Rgba8::from_rgba_hex(0xdc26_26ff)),
580    })
581}
582
583fn required_safe_string(props: &PrimitiveProps, name: &str) -> Result<String, String> {
584    let value = props
585        .string(name)
586        .ok_or_else(|| format!("{name} is required"))?;
587    if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) {
588        Err(format!("{name} must be 1-128 non-control characters"))
589    } else {
590        Ok(value.to_owned())
591    }
592}
593
594fn string_set(props: &PrimitiveProps, name: &str) -> Result<BTreeSet<String>, String> {
595    let Some(UiValue::Array(values)) = props.data(name) else {
596        return Err(format!("{name} must be an array"));
597    };
598    values
599        .iter()
600        .map(|value| match value {
601            UiValue::String(value)
602                if !value.is_empty()
603                    && value.len() <= 128
604                    && !value.chars().any(char::is_control) =>
605            {
606                Ok(value.clone())
607            }
608            _ => Err(format!("{name} entries must be safe strings")),
609        })
610        .collect()
611}
612
613fn operation_set(
614    props: &PrimitiveProps,
615    name: &str,
616) -> Result<BTreeSet<crate::interaction::DragOperation>, String> {
617    let Some(UiValue::Array(values)) = props.data(name) else {
618        return Err(format!("{name} must be an array"));
619    };
620    values
621        .iter()
622        .map(|value| match value {
623            UiValue::String(value) => crate::interaction::DragOperation::parse(value)
624                .ok_or_else(|| format!("{name} entries must be copy or move")),
625            _ => Err(format!("{name} entries must be strings")),
626        })
627        .collect()
628}
629
630fn drag_end_schema() -> ValueSchema {
631    ValueSchema::object(BTreeMap::from([
632        (
633            "accepted".to_owned(),
634            ObjectField::required(ValueSchema::Bool),
635        ),
636        (
637            "target_id".to_owned(),
638            ObjectField::required(ValueSchema::optional(ValueSchema::string())),
639        ),
640        (
641            "operation".to_owned(),
642            ObjectField::required(ValueSchema::String {
643                allowed: vec!["copy".to_owned(), "move".to_owned()],
644            }),
645        ),
646        (
647            "cancelled".to_owned(),
648            ObjectField::required(ValueSchema::Bool),
649        ),
650    ]))
651}
652
653fn drop_schema() -> ValueSchema {
654    ValueSchema::object(BTreeMap::from([
655        (
656            "source_id".to_owned(),
657            ObjectField::required(ValueSchema::string()),
658        ),
659        (
660            "target_id".to_owned(),
661            ObjectField::required(ValueSchema::string()),
662        ),
663        (
664            "payload_type".to_owned(),
665            ObjectField::required(ValueSchema::string()),
666        ),
667        (
668            "payload".to_owned(),
669            ObjectField::required(ValueSchema::UiValue),
670        ),
671        (
672            "operation".to_owned(),
673            ObjectField::required(ValueSchema::String {
674                allowed: vec!["copy".to_owned(), "move".to_owned()],
675            }),
676        ),
677        ("x".to_owned(), ObjectField::required(ValueSchema::number())),
678        ("y".to_owned(), ObjectField::required(ValueSchema::number())),
679    ]))
680}
681
682/// Build the native typed application drag-source schema.
683///
684/// # Panics
685///
686/// Panics only if the static primitive ID becomes invalid.
687#[must_use]
688pub fn drag_source_primitive_descriptor() -> PrimitiveDescriptor {
689    PrimitiveDescriptor {
690        id: PrimitiveId::parse("gpui_rhai.drag_source").expect("static primitive ID"),
691        export: "DragSourcePrimitive".to_owned(),
692        props: BTreeMap::from([
693            (
694                "source_id".to_owned(),
695                ObjectField::required(ValueSchema::string()),
696            ),
697            (
698                "payload_type".to_owned(),
699                ObjectField::required(ValueSchema::string()),
700            ),
701            (
702                "payload".to_owned(),
703                ObjectField::required(ValueSchema::UiValue),
704            ),
705            (
706                "operation".to_owned(),
707                ObjectField::required(ValueSchema::String {
708                    allowed: vec!["copy".to_owned(), "move".to_owned()],
709                }),
710            ),
711            (
712                "threshold".to_owned(),
713                ObjectField::optional(ValueSchema::bounded_number(Some(0.0), Some(64.0))),
714            ),
715            (
716                "keyboard_target".to_owned(),
717                ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
718            ),
719            (
720                "disabled".to_owned(),
721                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
722            ),
723            (
724                "on_drag_end".to_owned(),
725                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
726            ),
727        ]),
728        events: BTreeMap::from([(
729            "drag_end".to_owned(),
730            EventSchema {
731                payload: drag_end_schema(),
732            },
733        )]),
734        state: ComponentStateSchema::default(),
735        lifecycle: true,
736        effect: None,
737    }
738}
739
740/// Build the native typed application drop-target schema.
741///
742/// # Panics
743///
744/// Panics only if the static primitive ID becomes invalid.
745#[must_use]
746pub fn drop_zone_primitive_descriptor() -> PrimitiveDescriptor {
747    PrimitiveDescriptor {
748        id: PrimitiveId::parse("gpui_rhai.drop_zone").expect("static primitive ID"),
749        export: "DropZonePrimitive".to_owned(),
750        props: BTreeMap::from([
751            (
752                "target_id".to_owned(),
753                ObjectField::required(ValueSchema::string()),
754            ),
755            (
756                "payload_types".to_owned(),
757                ObjectField::required(ValueSchema::Array {
758                    items: Box::new(ValueSchema::string()),
759                    max_items: Some(32),
760                }),
761            ),
762            (
763                "operations".to_owned(),
764                ObjectField::required(ValueSchema::Array {
765                    items: Box::new(ValueSchema::String {
766                        allowed: vec!["copy".to_owned(), "move".to_owned()],
767                    }),
768                    max_items: Some(2),
769                }),
770            ),
771            (
772                "priority".to_owned(),
773                ObjectField::optional(ValueSchema::integer()),
774            ),
775            (
776                "disabled".to_owned(),
777                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
778            ),
779            (
780                "on_drop".to_owned(),
781                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
782            ),
783        ]),
784        events: BTreeMap::from([(
785            "drop".to_owned(),
786            EventSchema {
787                payload: drop_schema(),
788            },
789        )]),
790        state: ComponentStateSchema::default(),
791        lifecycle: false,
792        effect: None,
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    #[test]
801    fn typed_target_acceptance_distinguishes_payload_and_operation() {
802        let props = PrimitiveProps::new()
803            .with(
804                "payload_types",
805                crate::PrimitiveValue::Data(UiValue::Array(vec![UiValue::String(
806                    "card".to_owned(),
807                )])),
808            )
809            .with(
810                "operations",
811                crate::PrimitiveValue::Data(UiValue::Array(vec![UiValue::String(
812                    "move".to_owned(),
813                )])),
814            );
815        assert_eq!(
816            string_set(&props, "payload_types").unwrap(),
817            BTreeSet::from(["card".to_owned()])
818        );
819        assert_eq!(
820            operation_set(&props, "operations").unwrap(),
821            BTreeSet::from([crate::interaction::DragOperation::Move])
822        );
823    }
824}