Skip to main content

gpui_rhai/
rotatable.rs

1//! Controlled Canvas rotation around an explicit local pivot.
2
3use std::collections::BTreeMap;
4
5use gpui::{
6    AnyElement, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId, Hitbox,
7    HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement, KeyDownEvent, LayoutId,
8    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Style, Styled, Window, div,
9    relative, size,
10};
11
12use crate::{
13    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
14    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveProps, PrimitiveTheme, SignalKind,
15    SignalValue, UiValue, ValueSchema,
16};
17
18#[derive(Clone, Copy, Debug, PartialEq)]
19struct RotationPreview {
20    angle: f64,
21    translate_x: f64,
22    translate_y: f64,
23}
24
25#[derive(Clone)]
26struct RotatableConfig {
27    id: String,
28    source_token: String,
29    angle: f64,
30    pivot: (f64, f64),
31    snap: Option<f64>,
32    keyboard_step: f64,
33    threshold: f64,
34    disabled: bool,
35    content_ref: crate::ElementRef,
36    angle_signal: crate::NativeSignal,
37    x_signal: crate::NativeSignal,
38    y_signal: crate::NativeSignal,
39    source_token_signal: crate::NativeSignal,
40    focus: Option<gpui::FocusHandle>,
41}
42
43struct RotationPrepaint {
44    hitbox: Hitbox,
45}
46
47struct RotationElement {
48    config: RotatableConfig,
49    context: PrimitiveContext,
50}
51
52impl IntoElement for RotationElement {
53    type Element = Self;
54
55    fn into_element(self) -> Self::Element {
56        self
57    }
58}
59
60impl Element for RotationElement {
61    type RequestLayoutState = ();
62    type PrepaintState = RotationPrepaint;
63
64    fn id(&self) -> Option<ElementId> {
65        Some(ElementId::Name(self.config.id.clone().into()))
66    }
67
68    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
69        None
70    }
71
72    fn request_layout(
73        &mut self,
74        _: Option<&GlobalElementId>,
75        _: Option<&InspectorElementId>,
76        window: &mut Window,
77        cx: &mut App,
78    ) -> (LayoutId, ()) {
79        (
80            window.request_layout(
81                Style {
82                    size: size(relative(1.0).into(), relative(1.0).into()),
83                    ..Style::default()
84                },
85                None,
86                cx,
87            ),
88            (),
89        )
90    }
91
92    fn prepaint(
93        &mut self,
94        _: Option<&GlobalElementId>,
95        _: Option<&InspectorElementId>,
96        bounds: Bounds<Pixels>,
97        (): &mut (),
98        window: &mut Window,
99        cx: &mut App,
100    ) -> RotationPrepaint {
101        if let Some(viewport) = self.context.canvas_bounds(&self.config.content_ref, cx) {
102            sync_controlled_source(&self.context, &self.config, viewport, cx);
103        }
104        RotationPrepaint {
105            hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
106        }
107    }
108
109    #[allow(clippy::too_many_lines)]
110    fn paint(
111        &mut self,
112        _: Option<&GlobalElementId>,
113        _: Option<&InspectorElementId>,
114        _: Bounds<Pixels>,
115        (): &mut (),
116        prepaint: &mut RotationPrepaint,
117        window: &mut Window,
118        _: &mut App,
119    ) {
120        let owner = self.context.interaction_owner(&self.config.id);
121        self.context.present_interaction(owner.clone());
122        if self.config.disabled {
123            return;
124        }
125        let hitbox = prepaint.hitbox.clone();
126        let config = self.config.clone();
127        let context = self.context.clone();
128        let view = window.current_view();
129        window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
130            if phase != DispatchPhase::Bubble
131                || event.button != MouseButton::Left
132                || !hitbox.is_hovered(window)
133            {
134                return;
135            }
136            let Some(viewport) = context.canvas_bounds(&config.content_ref, cx) else {
137                return;
138            };
139            if let Some(focus) = config.focus.as_ref() {
140                focus.focus(window, cx);
141            }
142            sync_controlled_source(&context, &config, viewport, cx);
143            let pointer_start = pointer_angle(event.position, viewport, config.pivot);
144            let source_angle = config.angle;
145            let update_context = context.clone();
146            let update_config = config.clone();
147            let update = move |gesture: crate::interaction::GestureUpdate,
148                               _: &mut Window,
149                               cx: &mut App| {
150                if gesture.moved() {
151                    let Some(current_viewport) =
152                        update_context.canvas_bounds(&update_config.content_ref, cx)
153                    else {
154                        return crate::interaction::InteractionFlow::Cancel;
155                    };
156                    if current_viewport != viewport {
157                        return crate::interaction::InteractionFlow::Cancel;
158                    }
159                    let pointer =
160                        pointer_angle(gesture.current(), current_viewport, update_config.pivot);
161                    let next = rotated_angle(&update_config, source_angle, pointer - pointer_start);
162                    write_preview(&update_context, &update_config, current_viewport, next, cx);
163                }
164                crate::interaction::InteractionFlow::Continue
165            };
166            let finish_context = context.clone();
167            let finish_config = config.clone();
168            let finish = move |gesture: crate::interaction::GestureUpdate,
169                               window: &mut Window,
170                               cx: &mut App| {
171                let Some(current_viewport) =
172                    finish_context.canvas_bounds(&finish_config.content_ref, cx)
173                else {
174                    return;
175                };
176                if current_viewport != viewport {
177                    write_preview(
178                        &finish_context,
179                        &finish_config,
180                        current_viewport,
181                        finish_config.angle,
182                        cx,
183                    );
184                    return;
185                }
186                let pointer =
187                    pointer_angle(gesture.current(), current_viewport, finish_config.pivot);
188                let next = rotated_angle(&finish_config, source_angle, pointer - pointer_start);
189                write_preview(
190                    &finish_context,
191                    &finish_config,
192                    current_viewport,
193                    finish_config.angle,
194                    cx,
195                );
196                if gesture.moved() && angle_changed(finish_config.angle, next) {
197                    finish_context.propose("rotate", UiValue::Float(next), window, cx);
198                }
199            };
200            let cancel_context = context.clone();
201            let cancel_config = config.clone();
202            let cancel = move |_: &mut Window, cx: &mut App| {
203                if let Some(current_viewport) =
204                    cancel_context.canvas_bounds(&cancel_config.content_ref, cx)
205                {
206                    write_preview(
207                        &cancel_context,
208                        &cancel_config,
209                        current_viewport,
210                        cancel_config.angle,
211                        cx,
212                    );
213                }
214            };
215            context.begin_interaction(
216                crate::interaction::NativeGesture::new(
217                    owner.clone(),
218                    event.position,
219                    view,
220                    update,
221                    finish,
222                    cancel,
223                )
224                .with_threshold(config.threshold),
225                window,
226                cx,
227            );
228            cx.stop_propagation();
229        });
230    }
231}
232
233#[derive(Default)]
234pub struct RotatablePrimitiveHandler;
235
236impl PrimitiveHandler for RotatablePrimitiveHandler {
237    fn uses_primary_focus(&self) -> bool {
238        true
239    }
240
241    fn render(
242        &mut self,
243        instance: &PrimitiveInstance,
244        context: &PrimitiveContext,
245        _: &PrimitiveTheme,
246        _: &mut Window,
247        cx: &mut App,
248    ) -> Result<AnyElement, String> {
249        let config = parse_config(&instance.node.props, instance.focus_handle().cloned())?;
250        if let Some(viewport) = context.canvas_bounds(&config.content_ref, cx) {
251            sync_controlled_source(context, &config, viewport, cx);
252        }
253        let key_config = config.clone();
254        let key_context = context.clone();
255        let mut root = div().size_full();
256        if let Some(focus) = config.focus.as_ref() {
257            root = root.track_focus(&focus.clone().tab_stop(!config.disabled));
258        }
259        Ok(root
260            .on_key_down(move |event: &KeyDownEvent, window, cx: &mut App| {
261                if key_config.disabled {
262                    return;
263                }
264                let multiplier = if event.keystroke.modifiers.shift {
265                    4.0
266                } else {
267                    1.0
268                };
269                let step = key_config.snap.unwrap_or(key_config.keyboard_step) * multiplier;
270                let delta = match event.keystroke.key.as_str() {
271                    "left" | "down" => -step,
272                    "right" | "up" => step,
273                    "home" => -key_config.angle,
274                    _ => return,
275                };
276                let next = rotated_angle(&key_config, key_config.angle, delta);
277                if angle_changed(key_config.angle, next) {
278                    key_context.propose("rotate", UiValue::Float(next), window, cx);
279                }
280                cx.stop_propagation();
281            })
282            .child(RotationElement {
283                config,
284                context: context.clone(),
285            })
286            .into_any_element())
287    }
288}
289
290fn parse_config(
291    props: &PrimitiveProps,
292    focus: Option<gpui::FocusHandle>,
293) -> Result<RotatableConfig, String> {
294    let angle = required_number(props, "angle")?;
295    let pivot = (
296        required_number(props, "pivot_x")?,
297        required_number(props, "pivot_y")?,
298    );
299    let snap = match props.data("snap") {
300        None | Some(UiValue::Null) => None,
301        Some(_) => Some(required_number(props, "snap")?),
302    };
303    if snap.is_some_and(|snap| snap <= 0.0 || snap > 360.0)
304        || pivot.0.abs() > 1_000_000.0
305        || pivot.1.abs() > 1_000_000.0
306    {
307        return Err("rotatable pivot or snap is invalid".to_owned());
308    }
309    let keyboard_step = bounded_number(props, "keyboard_step", 0.1, 180.0, 5.0)?;
310    let threshold = bounded_number(props, "threshold", 0.0, 64.0, 4.0)?;
311    let angle_signal = typed_signal(props, "angle_signal", SignalKind::Float)?;
312    let x_signal = typed_signal(props, "x_signal", SignalKind::Float)?;
313    let y_signal = typed_signal(props, "y_signal", SignalKind::Float)?;
314    let source_token_signal = typed_signal(props, "source_token_signal", SignalKind::String)?;
315    let source_token = props
316        .string("source_token")
317        .filter(|value| !value.is_empty() && value.len() <= 512)
318        .ok_or_else(|| "rotatable source_token is required".to_owned())?
319        .to_owned();
320    Ok(RotatableConfig {
321        id: format!(
322            "gpui-rhai-rotatable:{}:{}",
323            angle_signal.id().component(),
324            angle_signal.id().key()
325        ),
326        source_token,
327        angle: wrap_angle(angle),
328        pivot,
329        snap,
330        keyboard_step,
331        threshold,
332        disabled: props.boolean("disabled").unwrap_or(false),
333        content_ref: props
334            .element_ref("content_ref")
335            .cloned()
336            .ok_or_else(|| "rotatable content_ref is required".to_owned())?,
337        angle_signal,
338        x_signal,
339        y_signal,
340        source_token_signal,
341        focus,
342    })
343}
344
345fn required_number(props: &PrimitiveProps, name: &str) -> Result<f64, String> {
346    props
347        .number(name)
348        .filter(|value| value.is_finite())
349        .ok_or_else(|| format!("rotatable requires finite numeric {name}"))
350}
351
352fn bounded_number(
353    props: &PrimitiveProps,
354    name: &str,
355    minimum: f64,
356    maximum: f64,
357    fallback: f64,
358) -> Result<f64, String> {
359    let value = props.number(name).unwrap_or(fallback);
360    (value.is_finite() && (minimum..=maximum).contains(&value))
361        .then_some(value)
362        .ok_or_else(|| format!("rotatable {name} is outside its supported range"))
363}
364
365fn typed_signal(
366    props: &PrimitiveProps,
367    name: &str,
368    kind: SignalKind,
369) -> Result<crate::NativeSignal, String> {
370    let signal = props
371        .signal(name)
372        .cloned()
373        .ok_or_else(|| format!("rotatable requires signal {name}"))?;
374    (signal.id().kind() == kind)
375        .then_some(signal)
376        .ok_or_else(|| format!("rotatable {name} must be {}", kind.as_str()))
377}
378
379fn pointer_angle(
380    position: Point<Pixels>,
381    viewport: crate::GeometryBounds,
382    pivot: (f64, f64),
383) -> f64 {
384    let x = f64::from(position.x) - viewport.x - pivot.0;
385    let y = f64::from(position.y) - viewport.y - pivot.1;
386    y.atan2(x).to_degrees()
387}
388
389fn rotated_angle(config: &RotatableConfig, source: f64, delta: f64) -> f64 {
390    let angle = wrap_angle(source + normalize_delta(delta));
391    config
392        .snap
393        .map_or(angle, |snap| wrap_angle((angle / snap).round() * snap))
394}
395
396fn wrap_angle(angle: f64) -> f64 {
397    angle.rem_euclid(360.0)
398}
399
400fn normalize_delta(delta: f64) -> f64 {
401    (delta + 180.0).rem_euclid(360.0) - 180.0
402}
403
404fn angle_changed(left: f64, right: f64) -> bool {
405    normalize_delta(right - left).abs() > 0.000_001
406}
407
408fn preview_for(angle: f64, pivot: (f64, f64), viewport: crate::GeometryBounds) -> RotationPreview {
409    let center = (viewport.width / 2.0, viewport.height / 2.0);
410    let offset = (pivot.0 - center.0, pivot.1 - center.1);
411    let radians = angle.to_radians();
412    let (sin, cos) = radians.sin_cos();
413    let rotated = (
414        cos.mul_add(offset.0, -sin * offset.1),
415        sin.mul_add(offset.0, cos * offset.1),
416    );
417    RotationPreview {
418        angle,
419        translate_x: offset.0 - rotated.0,
420        translate_y: offset.1 - rotated.1,
421    }
422}
423
424fn write_preview(
425    context: &PrimitiveContext,
426    config: &RotatableConfig,
427    viewport: crate::GeometryBounds,
428    angle: f64,
429    cx: &mut App,
430) {
431    let preview = preview_for(angle, config.pivot, viewport);
432    let _ = context.write_signals(
433        [
434            (
435                config.angle_signal.clone(),
436                SignalValue::Float(preview.angle),
437            ),
438            (
439                config.x_signal.clone(),
440                SignalValue::Float(preview.translate_x),
441            ),
442            (
443                config.y_signal.clone(),
444                SignalValue::Float(preview.translate_y),
445            ),
446        ],
447        cx,
448    );
449}
450
451fn sync_controlled_source(
452    context: &PrimitiveContext,
453    config: &RotatableConfig,
454    viewport: crate::GeometryBounds,
455    cx: &mut App,
456) {
457    let presentation_token = format!(
458        "{}|{}|{}",
459        config.source_token, viewport.width, viewport.height
460    );
461    let token_matches = matches!(
462        context.read_signal(&config.source_token_signal, cx),
463        Ok(SignalValue::String(value)) if value == presentation_token
464    );
465    if token_matches {
466        return;
467    }
468    write_preview(context, config, viewport, config.angle, cx);
469    let _ = context.write_signal(
470        &config.source_token_signal,
471        SignalValue::String(presentation_token),
472        cx,
473    );
474}
475
476fn descriptor_signal_props() -> BTreeMap<String, ObjectField> {
477    [
478        "angle_signal",
479        "x_signal",
480        "y_signal",
481        "source_token_signal",
482    ]
483    .into_iter()
484    .map(|name| (name.to_owned(), ObjectField::required(ValueSchema::Signal)))
485    .collect()
486}
487
488/// Build the native controlled Canvas rotation schema.
489///
490/// # Panics
491///
492/// Panics only if the static primitive ID becomes invalid.
493#[must_use]
494pub fn rotatable_primitive_descriptor() -> PrimitiveDescriptor {
495    let mut props = BTreeMap::from([
496        (
497            "source_token".to_owned(),
498            ObjectField::required(ValueSchema::string()),
499        ),
500        (
501            "angle".to_owned(),
502            ObjectField::required(ValueSchema::number()),
503        ),
504        (
505            "pivot_x".to_owned(),
506            ObjectField::required(ValueSchema::number()),
507        ),
508        (
509            "pivot_y".to_owned(),
510            ObjectField::required(ValueSchema::number()),
511        ),
512        (
513            "snap".to_owned(),
514            ObjectField::optional(ValueSchema::optional(ValueSchema::number())),
515        ),
516        (
517            "keyboard_step".to_owned(),
518            ObjectField::optional(ValueSchema::bounded_number(Some(0.1), Some(180.0))),
519        ),
520        (
521            "threshold".to_owned(),
522            ObjectField::optional(ValueSchema::bounded_number(Some(0.0), Some(64.0))),
523        ),
524        (
525            "disabled".to_owned(),
526            ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
527        ),
528        (
529            "content_ref".to_owned(),
530            ObjectField::required(ValueSchema::Ref),
531        ),
532        (
533            "on_rotate".to_owned(),
534            ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
535        ),
536    ]);
537    props.extend(descriptor_signal_props());
538    PrimitiveDescriptor {
539        id: PrimitiveId::parse("gpui_rhai.rotatable").expect("static primitive ID"),
540        export: "RotatablePrimitive".to_owned(),
541        props,
542        events: BTreeMap::from([(
543            "rotate".to_owned(),
544            EventSchema {
545                payload: ValueSchema::number(),
546            },
547        )]),
548        state: ComponentStateSchema::default(),
549        lifecycle: false,
550        effect: None,
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn arbitrary_pivot_stays_fixed_under_rotation_compensation() {
560        let viewport = crate::GeometryBounds::new(0.0, 0.0, 300.0, 180.0).unwrap();
561        let pivot = (40.0, 70.0);
562        let preview = preview_for(90.0, pivot, viewport);
563        let transformed = crate::canvas::canvas_motion_point(
564            viewport.width,
565            viewport.height,
566            pivot.0,
567            pivot.1,
568            crate::geometry::CanvasMotionTransform {
569                rotate: preview.angle,
570                ..crate::geometry::CanvasMotionTransform::default()
571            },
572        );
573        assert!((transformed.0 + preview.translate_x - pivot.0).abs() < 0.000_001);
574        assert!((transformed.1 + preview.translate_y - pivot.1).abs() < 0.000_001);
575    }
576}