1use 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#[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}