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