Skip to main content

gpui_rhai/
draggable.rs

1//! Native pointer hot lane for the public controlled `Draggable` component.
2
3use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::rc::Rc;
6
7use gpui::{
8    AnyElement, App, Bounds, CursorStyle, DispatchPhase, Element, ElementId, FocusHandle,
9    GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
10    KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels, Style, Styled,
11    Window, div, relative, size,
12};
13
14use crate::{
15    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
16    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveProps, PrimitiveTheme, SignalKind,
17    SignalValue, UiValue, ValueSchema,
18};
19
20const MAX_POSITION: f64 = 1_000_000.0;
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23enum DragAxes {
24    Both,
25    Horizontal,
26    Vertical,
27}
28
29#[derive(Clone)]
30struct DragConfig {
31    id: String,
32    source_x: f64,
33    source_y: f64,
34    axes: DragAxes,
35    contain: bool,
36    threshold: f64,
37    snap_x: Option<f64>,
38    snap_y: Option<f64>,
39    keyboard_step: f64,
40    disabled: bool,
41    boundary_ref: crate::ElementRef,
42    object_ref: crate::ElementRef,
43    handle_ref: Option<crate::ElementRef>,
44    x_signal: crate::NativeSignal,
45    y_signal: crate::NativeSignal,
46    focus: Option<FocusHandle>,
47}
48
49#[derive(Clone, Debug, PartialEq)]
50struct DragSourceIdentity {
51    x: f64,
52    y: f64,
53    x_signal: crate::SignalId,
54    y_signal: crate::SignalId,
55    disabled: bool,
56}
57
58#[derive(Clone)]
59struct DragState(Rc<RefCell<DragSourceIdentity>>);
60
61impl DragState {
62    fn new(config: &DragConfig) -> Self {
63        Self(Rc::new(RefCell::new(source_identity(config))))
64    }
65}
66
67fn source_identity(config: &DragConfig) -> DragSourceIdentity {
68    DragSourceIdentity {
69        x: config.source_x,
70        y: config.source_y,
71        x_signal: config.x_signal.id().clone(),
72        y_signal: config.y_signal.id().clone(),
73        disabled: config.disabled,
74    }
75}
76
77struct DragPrepaint {
78    hitbox: Hitbox,
79}
80
81struct DragHandleElement {
82    config: DragConfig,
83    context: PrimitiveContext,
84}
85
86impl IntoElement for DragHandleElement {
87    type Element = Self;
88
89    fn into_element(self) -> Self::Element {
90        self
91    }
92}
93
94impl Element for DragHandleElement {
95    type RequestLayoutState = ();
96    type PrepaintState = DragPrepaint;
97
98    fn id(&self) -> Option<ElementId> {
99        Some(ElementId::Name(self.config.id.clone().into()))
100    }
101
102    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
103        None
104    }
105
106    fn request_layout(
107        &mut self,
108        _: Option<&GlobalElementId>,
109        _: Option<&InspectorElementId>,
110        window: &mut Window,
111        cx: &mut App,
112    ) -> (LayoutId, ()) {
113        (
114            window.request_layout(
115                Style {
116                    size: size(relative(1.0).into(), relative(1.0).into()),
117                    ..Style::default()
118                },
119                None,
120                cx,
121            ),
122            (),
123        )
124    }
125
126    fn prepaint(
127        &mut self,
128        _: Option<&GlobalElementId>,
129        _: Option<&InspectorElementId>,
130        bounds: Bounds<Pixels>,
131        (): &mut (),
132        window: &mut Window,
133        cx: &mut App,
134    ) -> DragPrepaint {
135        let state = window
136            .use_state(cx, |_, _| DragState::new(&self.config))
137            .read(cx)
138            .clone();
139        let changed = {
140            let next = source_identity(&self.config);
141            let mut source = state.0.borrow_mut();
142            let changed = *source != next;
143            *source = next;
144            changed
145        };
146        let owner = self.context.interaction_owner(&self.config.id);
147        if changed && !self.context.cancel_interaction(&owner, window, cx) {
148            clear_preview(&self.context, &self.config, cx);
149        }
150        DragPrepaint {
151            hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
152        }
153    }
154
155    fn paint(
156        &mut self,
157        _: Option<&GlobalElementId>,
158        _: Option<&InspectorElementId>,
159        _: Bounds<Pixels>,
160        (): &mut (),
161        prepaint: &mut DragPrepaint,
162        window: &mut Window,
163        _: &mut App,
164    ) {
165        let owner = self.context.interaction_owner(&self.config.id);
166        self.context.present_interaction(owner);
167        if !self.config.disabled {
168            window.set_cursor_style(CursorStyle::OpenHand, &prepaint.hitbox);
169        }
170        register_pointer_down(prepaint, &self.config, &self.context, window);
171    }
172}
173
174fn register_pointer_down(
175    prepaint: &DragPrepaint,
176    config: &DragConfig,
177    context: &PrimitiveContext,
178    window: &mut Window,
179) {
180    let hitbox = prepaint.hitbox.clone();
181    let config = config.clone();
182    let context = context.clone();
183    let view = window.current_view();
184    window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
185        if phase != DispatchPhase::Bubble
186            || event.button != MouseButton::Left
187            || config.disabled
188            || !hitbox.is_hovered(window)
189        {
190            return;
191        }
192        let eligible_ref = config.handle_ref.as_ref().unwrap_or(&config.object_ref);
193        let Some(eligible) = context.element_bounds(eligible_ref, cx) else {
194            return;
195        };
196        if !contains(eligible, event.position) {
197            return;
198        }
199        let (Some(boundary), Some(object)) = (
200            context.element_bounds(&config.boundary_ref, cx),
201            context.element_bounds(&config.object_ref, cx),
202        ) else {
203            return;
204        };
205        if config.contain
206            && (config.source_x < 0.0
207                || config.source_y < 0.0
208                || config.source_x + object.width > boundary.width + 0.5
209                || config.source_y + object.height > boundary.height + 0.5)
210        {
211            return;
212        }
213        if let Some(focus) = config.focus.as_ref() {
214            focus.focus(window, cx);
215        }
216        let boundary_size = (boundary.width, boundary.height);
217        let object_size = (object.width, object.height);
218        let update_config = config.clone();
219        let update_context = context.clone();
220        let update =
221            move |gesture: crate::interaction::GestureUpdate, _: &mut Window, cx: &mut App| {
222                let Some(boundary) = update_context.element_bounds(&update_config.boundary_ref, cx)
223                else {
224                    return crate::interaction::InteractionFlow::Cancel;
225                };
226                if (boundary.width - boundary_size.0).abs() > 0.5
227                    || (boundary.height - boundary_size.1).abs() > 0.5
228                {
229                    return crate::interaction::InteractionFlow::Cancel;
230                }
231                if gesture.moved() {
232                    let next =
233                        drag_position(&update_config, gesture.delta(), boundary_size, object_size);
234                    write_preview(&update_context, &update_config, Some(next), cx);
235                }
236                crate::interaction::InteractionFlow::Continue
237            };
238        let finish_config = config.clone();
239        let finish_context = context.clone();
240        let finish =
241            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
242                clear_preview(&finish_context, &finish_config, cx);
243                if gesture.moved() {
244                    let next =
245                        drag_position(&finish_config, gesture.delta(), boundary_size, object_size);
246                    if position_changed(&finish_config, next) {
247                        finish_context.propose("move", position_value(next), window, cx);
248                    }
249                }
250            };
251        let cancel_config = config.clone();
252        let cancel_context = context.clone();
253        let cancel = move |_: &mut Window, cx: &mut App| {
254            clear_preview(&cancel_context, &cancel_config, cx);
255        };
256        let owner = context.interaction_owner(&config.id);
257        context.begin_interaction(
258            crate::interaction::NativeGesture::new(
259                owner,
260                event.position,
261                view,
262                update,
263                finish,
264                cancel,
265            )
266            .with_threshold(config.threshold),
267            window,
268            cx,
269        );
270        cx.stop_propagation();
271    });
272}
273
274fn contains(bounds: crate::GeometryBounds, point: gpui::Point<Pixels>) -> bool {
275    let x = f64::from(point.x);
276    let y = f64::from(point.y);
277    x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height
278}
279
280fn drag_position(
281    config: &DragConfig,
282    delta: (f64, f64),
283    boundary: (f64, f64),
284    object: (f64, f64),
285) -> (f64, f64) {
286    let mut x = config.source_x;
287    let mut y = config.source_y;
288    if matches!(config.axes, DragAxes::Both | DragAxes::Horizontal) {
289        x += delta.0;
290        x = snap(x, config.snap_x);
291    }
292    if matches!(config.axes, DragAxes::Both | DragAxes::Vertical) {
293        y += delta.1;
294        y = snap(y, config.snap_y);
295    }
296    if config.contain {
297        x = x.clamp(0.0, (boundary.0 - object.0).max(0.0));
298        y = y.clamp(0.0, (boundary.1 - object.1).max(0.0));
299    }
300    (x, y)
301}
302
303fn snap(value: f64, step: Option<f64>) -> f64 {
304    step.map_or(value, |step| (value / step).round() * step)
305}
306
307fn write_preview(
308    context: &PrimitiveContext,
309    config: &DragConfig,
310    position: Option<(f64, f64)>,
311    cx: &mut App,
312) {
313    let _ = context.write_signals(
314        [
315            (
316                config.x_signal.clone(),
317                SignalValue::OptionalFloat(position.map(|position| position.0 - config.source_x)),
318            ),
319            (
320                config.y_signal.clone(),
321                SignalValue::OptionalFloat(position.map(|position| position.1 - config.source_y)),
322            ),
323        ],
324        cx,
325    );
326}
327
328fn clear_preview(context: &PrimitiveContext, config: &DragConfig, cx: &mut App) {
329    write_preview(context, config, None, cx);
330}
331
332fn position_value(position: (f64, f64)) -> UiValue {
333    UiValue::Map(BTreeMap::from([
334        ("x".to_owned(), UiValue::Float(position.0)),
335        ("y".to_owned(), UiValue::Float(position.1)),
336    ]))
337}
338
339fn position_changed(config: &DragConfig, position: (f64, f64)) -> bool {
340    (position.0 - config.source_x).abs() > f64::EPSILON
341        || (position.1 - config.source_y).abs() > f64::EPSILON
342}
343
344#[derive(Default)]
345pub struct DraggablePrimitiveHandler;
346
347impl PrimitiveHandler for DraggablePrimitiveHandler {
348    fn uses_primary_focus(&self) -> bool {
349        true
350    }
351
352    fn render(
353        &mut self,
354        instance: &PrimitiveInstance,
355        context: &PrimitiveContext,
356        _: &PrimitiveTheme,
357        _: &mut Window,
358        _: &mut App,
359    ) -> Result<AnyElement, String> {
360        let config = parse_config(&instance.node.props, instance.focus_handle().cloned())?;
361        let key_config = config.clone();
362        let key_context = context.clone();
363        let mut root = div().size_full();
364        if let Some(focus) = config.focus.as_ref() {
365            root = root.track_focus(&focus.clone().tab_stop(!config.disabled));
366        }
367        Ok(root
368            .on_key_down(move |event: &KeyDownEvent, window, cx: &mut App| {
369                if key_config.disabled {
370                    return;
371                }
372                let multiplier = if event.keystroke.modifiers.shift {
373                    4.0
374                } else {
375                    1.0
376                };
377                let step = key_config.keyboard_step * multiplier;
378                let delta = match event.keystroke.key.as_str() {
379                    "left" => (-step, 0.0),
380                    "right" => (step, 0.0),
381                    "up" => (0.0, -step),
382                    "down" => (0.0, step),
383                    _ => return,
384                };
385                let (Some(boundary), Some(object)) = (
386                    key_context.element_bounds(&key_config.boundary_ref, cx),
387                    key_context.element_bounds(&key_config.object_ref, cx),
388                ) else {
389                    return;
390                };
391                let next = drag_position(
392                    &key_config,
393                    delta,
394                    (boundary.width, boundary.height),
395                    (object.width, object.height),
396                );
397                if position_changed(&key_config, next) {
398                    key_context.propose("move", position_value(next), window, cx);
399                }
400                cx.stop_propagation();
401            })
402            .child(DragHandleElement {
403                config,
404                context: context.clone(),
405            })
406            .into_any_element())
407    }
408}
409
410fn parse_config(props: &PrimitiveProps, focus: Option<FocusHandle>) -> Result<DragConfig, String> {
411    let source_x = required_number(props, "x")?;
412    let source_y = required_number(props, "y")?;
413    if source_x.abs() > MAX_POSITION || source_y.abs() > MAX_POSITION {
414        return Err("draggable position exceeds the supported range".to_owned());
415    }
416    let axes = match props.string("axes") {
417        None | Some("both") => DragAxes::Both,
418        Some("horizontal") => DragAxes::Horizontal,
419        Some("vertical") => DragAxes::Vertical,
420        Some(_) => return Err("draggable axes must be both, horizontal, or vertical".to_owned()),
421    };
422    let threshold = props.number("threshold").unwrap_or(4.0);
423    let keyboard_step = props.number("keyboard_step").unwrap_or(8.0);
424    let snap_x = optional_positive_number(props, "snap_x")?;
425    let snap_y = optional_positive_number(props, "snap_y")?;
426    if !threshold.is_finite()
427        || !(0.0..=64.0).contains(&threshold)
428        || !keyboard_step.is_finite()
429        || !(0.0..=512.0).contains(&keyboard_step)
430        || keyboard_step == 0.0
431    {
432        return Err("draggable threshold or keyboard_step is invalid".to_owned());
433    }
434    let x_signal = optional_float_signal(props, "x_signal")?;
435    let y_signal = optional_float_signal(props, "y_signal")?;
436    Ok(DragConfig {
437        id: format!(
438            "gpui-rhai-draggable:{}:{}",
439            x_signal.id().component(),
440            x_signal.id().key()
441        ),
442        source_x,
443        source_y,
444        axes,
445        contain: props.boolean("contain").unwrap_or(true),
446        threshold,
447        snap_x,
448        snap_y,
449        keyboard_step,
450        disabled: props.boolean("disabled").unwrap_or(false),
451        boundary_ref: props
452            .element_ref("boundary_ref")
453            .cloned()
454            .ok_or_else(|| "draggable requires boundary_ref".to_owned())?,
455        object_ref: props
456            .element_ref("object_ref")
457            .cloned()
458            .ok_or_else(|| "draggable requires object_ref".to_owned())?,
459        handle_ref: props.element_ref("handle_ref").cloned(),
460        x_signal,
461        y_signal,
462        focus,
463    })
464}
465
466fn required_number(props: &PrimitiveProps, name: &str) -> Result<f64, String> {
467    props
468        .number(name)
469        .filter(|value| value.is_finite())
470        .ok_or_else(|| format!("draggable requires finite numeric {name}"))
471}
472
473fn optional_positive_number(props: &PrimitiveProps, name: &str) -> Result<Option<f64>, String> {
474    match props.data(name) {
475        None | Some(UiValue::Null) => Ok(None),
476        Some(_) => props
477            .number(name)
478            .filter(|value| value.is_finite() && *value > 0.0)
479            .map(Some)
480            .ok_or_else(|| format!("draggable {name} must be a positive finite number")),
481    }
482}
483
484fn optional_float_signal(
485    props: &PrimitiveProps,
486    name: &str,
487) -> Result<crate::NativeSignal, String> {
488    let signal = props
489        .signal(name)
490        .cloned()
491        .ok_or_else(|| format!("draggable requires signal {name}"))?;
492    if signal.id().kind() != SignalKind::OptionalFloat {
493        return Err(format!("draggable {name} must be optional_float"));
494    }
495    Ok(signal)
496}
497
498fn position_schema() -> ValueSchema {
499    ValueSchema::object(BTreeMap::from([
500        ("x".to_owned(), ObjectField::required(ValueSchema::number())),
501        ("y".to_owned(), ObjectField::required(ValueSchema::number())),
502    ]))
503}
504
505/// Build the native controlled-position interaction schema.
506///
507/// # Panics
508///
509/// Panics only if the static built-in primitive ID becomes invalid.
510#[must_use]
511pub fn draggable_primitive_descriptor() -> PrimitiveDescriptor {
512    let optional_number = || ObjectField::optional(ValueSchema::optional(ValueSchema::number()));
513    PrimitiveDescriptor {
514        id: PrimitiveId::parse("gpui_rhai.draggable").expect("static primitive ID"),
515        export: "DraggablePrimitive".to_owned(),
516        props: BTreeMap::from([
517            ("x".to_owned(), ObjectField::required(ValueSchema::number())),
518            ("y".to_owned(), ObjectField::required(ValueSchema::number())),
519            (
520                "axes".to_owned(),
521                ObjectField::optional(ValueSchema::String {
522                    allowed: vec![
523                        "both".to_owned(),
524                        "horizontal".to_owned(),
525                        "vertical".to_owned(),
526                    ],
527                })
528                .with_default(UiValue::String("both".to_owned())),
529            ),
530            (
531                "contain".to_owned(),
532                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(true)),
533            ),
534            (
535                "threshold".to_owned(),
536                ObjectField::optional(ValueSchema::bounded_number(Some(0.0), Some(64.0))),
537            ),
538            ("snap_x".to_owned(), optional_number()),
539            ("snap_y".to_owned(), optional_number()),
540            (
541                "keyboard_step".to_owned(),
542                ObjectField::optional(ValueSchema::positive_number()),
543            ),
544            (
545                "disabled".to_owned(),
546                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
547            ),
548            (
549                "boundary_ref".to_owned(),
550                ObjectField::required(ValueSchema::Ref),
551            ),
552            (
553                "object_ref".to_owned(),
554                ObjectField::required(ValueSchema::Ref),
555            ),
556            (
557                "handle_ref".to_owned(),
558                ObjectField::optional(ValueSchema::optional(ValueSchema::Ref)),
559            ),
560            (
561                "x_signal".to_owned(),
562                ObjectField::required(ValueSchema::Signal),
563            ),
564            (
565                "y_signal".to_owned(),
566                ObjectField::required(ValueSchema::Signal),
567            ),
568            (
569                "on_move".to_owned(),
570                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
571            ),
572        ]),
573        events: BTreeMap::from([(
574            "move".to_owned(),
575            EventSchema {
576                payload: position_schema(),
577            },
578        )]),
579        state: ComponentStateSchema::default(),
580        lifecycle: false,
581        effect: None,
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    fn config(axes: DragAxes) -> DragConfig {
590        let component = crate::ComponentInstancePath::root("Draggable", "card");
591        DragConfig {
592            id: "card".to_owned(),
593            source_x: 20.0,
594            source_y: 30.0,
595            axes,
596            contain: true,
597            threshold: 4.0,
598            snap_x: Some(10.0),
599            snap_y: None,
600            keyboard_step: 8.0,
601            disabled: false,
602            boundary_ref: crate::ElementRef::new(
603                crate::ElementRefId::new(component.clone(), "boundary").unwrap(),
604            ),
605            object_ref: crate::ElementRef::new(
606                crate::ElementRefId::new(component.clone(), "object").unwrap(),
607            ),
608            handle_ref: None,
609            x_signal: crate::NativeSignal::new(
610                crate::SignalId::new(component.clone(), "x", SignalKind::OptionalFloat).unwrap(),
611            ),
612            y_signal: crate::NativeSignal::new(
613                crate::SignalId::new(component, "y", SignalKind::OptionalFloat).unwrap(),
614            ),
615            focus: None,
616        }
617    }
618
619    #[test]
620    fn drag_axes_snap_and_containment_are_one_pure_policy() {
621        assert_eq!(
622            drag_position(
623                &config(DragAxes::Both),
624                (17.0, 50.0),
625                (100.0, 90.0),
626                (30.0, 20.0)
627            ),
628            (40.0, 70.0)
629        );
630        assert_eq!(
631            drag_position(
632                &config(DragAxes::Horizontal),
633                (99.0, 50.0),
634                (100.0, 90.0),
635                (30.0, 20.0)
636            ),
637            (70.0, 30.0)
638        );
639    }
640}