1use std::cell::{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, fill, point, px, relative, rgba, size,
12};
13
14use crate::{
15 ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
16 PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps,
17 PrimitiveTheme, PrimitiveValue, Rgba8, SignalKind, SignalValue, UiValue, ValueSchema,
18};
19
20const MAX_RESIZE_DIMENSION: f64 = 16_384.0;
21
22#[derive(Clone, Copy, Debug, PartialEq)]
23struct ResizeRect {
24 x: f64,
25 y: f64,
26 width: f64,
27 height: f64,
28}
29
30impl ResizeRect {
31 fn right(self) -> f64 {
32 self.x + self.width
33 }
34
35 fn bottom(self) -> f64 {
36 self.y + self.height
37 }
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41enum ResizeHandle {
42 North,
43 South,
44 East,
45 West,
46 NorthEast,
47 NorthWest,
48 SouthEast,
49 SouthWest,
50}
51
52impl ResizeHandle {
53 fn parse(value: &str) -> Option<Self> {
54 match value {
55 "n" => Some(Self::North),
56 "s" => Some(Self::South),
57 "e" => Some(Self::East),
58 "w" => Some(Self::West),
59 "ne" => Some(Self::NorthEast),
60 "nw" => Some(Self::NorthWest),
61 "se" => Some(Self::SouthEast),
62 "sw" => Some(Self::SouthWest),
63 _ => None,
64 }
65 }
66
67 const fn as_str(self) -> &'static str {
68 match self {
69 Self::North => "n",
70 Self::South => "s",
71 Self::East => "e",
72 Self::West => "w",
73 Self::NorthEast => "ne",
74 Self::NorthWest => "nw",
75 Self::SouthEast => "se",
76 Self::SouthWest => "sw",
77 }
78 }
79
80 const fn moves_west(self) -> bool {
81 matches!(self, Self::West | Self::NorthWest | Self::SouthWest)
82 }
83
84 const fn moves_east(self) -> bool {
85 matches!(self, Self::East | Self::NorthEast | Self::SouthEast)
86 }
87
88 const fn moves_north(self) -> bool {
89 matches!(self, Self::North | Self::NorthEast | Self::NorthWest)
90 }
91
92 const fn moves_south(self) -> bool {
93 matches!(self, Self::South | Self::SouthEast | Self::SouthWest)
94 }
95
96 const fn moves_horizontal(self) -> bool {
97 self.moves_west() || self.moves_east()
98 }
99
100 const fn moves_vertical(self) -> bool {
101 self.moves_north() || self.moves_south()
102 }
103
104 const fn cursor(self) -> CursorStyle {
105 match self {
106 Self::North | Self::South => CursorStyle::ResizeUpDown,
107 Self::East | Self::West => CursorStyle::ResizeLeftRight,
108 Self::NorthEast | Self::SouthWest => CursorStyle::ResizeUpRightDownLeft,
109 Self::NorthWest | Self::SouthEast => CursorStyle::ResizeUpLeftDownRight,
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq)]
115struct ResizeConstraints {
116 min_width: f64,
117 min_height: f64,
118 max_width: f64,
119 max_height: f64,
120 aspect_ratio: Option<f64>,
121 contain: bool,
122}
123
124#[derive(Clone)]
125struct ResizableConfig {
126 id: String,
127 handle: ResizeHandle,
128 source: ResizeRect,
129 constraints: ResizeConstraints,
130 boundary_ref: crate::ElementRef,
131 boundary_size: Rc<Cell<Option<(f64, f64)>>>,
132 x_signal: crate::NativeSignal,
133 y_signal: crate::NativeSignal,
134 width_signal: crate::NativeSignal,
135 height_signal: crate::NativeSignal,
136 keyboard_step: f64,
137 disabled: bool,
138 idle_color: Rgba8,
139 active_color: Rgba8,
140 focus: Option<FocusHandle>,
141}
142
143#[derive(Clone)]
144struct ResizableState(Rc<RefCell<ResizableStateInner>>);
145
146#[derive(Clone)]
147struct ResizableStateInner {
148 source: ResizeRect,
149 constraints: ResizeConstraints,
150 signals: [crate::SignalId; 4],
151}
152
153impl ResizableState {
154 fn new(config: &ResizableConfig) -> Self {
155 Self(Rc::new(RefCell::new(ResizableStateInner {
156 source: config.source,
157 constraints: config.constraints,
158 signals: signal_ids(config),
159 })))
160 }
161}
162
163fn signal_ids(config: &ResizableConfig) -> [crate::SignalId; 4] {
164 [
165 config.x_signal.id().clone(),
166 config.y_signal.id().clone(),
167 config.width_signal.id().clone(),
168 config.height_signal.id().clone(),
169 ]
170}
171
172struct ResizablePrepaint {
173 hitbox: Hitbox,
174}
175
176struct ResizableHandleElement {
177 config: ResizableConfig,
178 events: PrimitiveContext,
179}
180
181impl IntoElement for ResizableHandleElement {
182 type Element = Self;
183
184 fn into_element(self) -> Self::Element {
185 self
186 }
187}
188
189impl Element for ResizableHandleElement {
190 type RequestLayoutState = ();
191 type PrepaintState = ResizablePrepaint;
192
193 fn id(&self) -> Option<ElementId> {
194 Some(ElementId::Name(self.config.id.clone().into()))
195 }
196
197 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
198 None
199 }
200
201 fn request_layout(
202 &mut self,
203 _: Option<&GlobalElementId>,
204 _: Option<&InspectorElementId>,
205 window: &mut Window,
206 cx: &mut App,
207 ) -> (LayoutId, Self::RequestLayoutState) {
208 (
209 window.request_layout(
210 Style {
211 size: size(relative(1.0).into(), relative(1.0).into()),
212 ..Style::default()
213 },
214 None,
215 cx,
216 ),
217 (),
218 )
219 }
220
221 fn prepaint(
222 &mut self,
223 _: Option<&GlobalElementId>,
224 _: Option<&InspectorElementId>,
225 bounds: Bounds<Pixels>,
226 (): &mut Self::RequestLayoutState,
227 window: &mut Window,
228 cx: &mut App,
229 ) -> Self::PrepaintState {
230 let state = window
231 .use_state(cx, |_, _| ResizableState::new(&self.config))
232 .read(cx)
233 .clone();
234 let reset = {
235 let mut inner = state.0.borrow_mut();
236 let signals = signal_ids(&self.config);
237 let changed = inner.source != self.config.source
238 || inner.constraints != self.config.constraints
239 || inner.signals != signals;
240 if changed {
241 inner.source = self.config.source;
242 inner.constraints = self.config.constraints;
243 inner.signals = signals;
244 }
245 changed
246 };
247 let owner = self.events.interaction_owner(&self.config.id);
248 self.config.boundary_size.set(
249 self.events
250 .element_bounds(&self.config.boundary_ref, cx)
251 .map(|bounds| (bounds.width, bounds.height)),
252 );
253 if reset && !self.events.cancel_interaction(&owner, window, cx) {
254 clear_preview(&self.events, &self.config, window, cx);
255 }
256 ResizablePrepaint {
257 hitbox: window.insert_hitbox(bounds, HitboxBehavior::BlockMouseExceptScroll),
258 }
259 }
260
261 fn paint(
262 &mut self,
263 _: Option<&GlobalElementId>,
264 _: Option<&InspectorElementId>,
265 bounds: Bounds<Pixels>,
266 (): &mut Self::RequestLayoutState,
267 prepaint: &mut Self::PrepaintState,
268 window: &mut Window,
269 _: &mut App,
270 ) {
271 let owner = self.events.interaction_owner(&self.config.id);
272 self.events.present_interaction(owner.clone());
273 let dragging = self.events.interaction_is_active(&owner);
274 let hovered = !self.config.disabled && prepaint.hitbox.is_hovered(window);
275 let color = if dragging || hovered {
276 self.config.active_color
277 } else {
278 self.config.idle_color
279 };
280 paint_handle(bounds, self.config.handle, color, dragging, window);
281 if !self.config.disabled {
282 window.set_cursor_style(self.config.handle.cursor(), &prepaint.hitbox);
283 }
284 register_pointer_listeners(prepaint, &self.config, &self.events, window);
285 }
286}
287
288fn paint_handle(
289 bounds: Bounds<Pixels>,
290 handle: ResizeHandle,
291 color: Rgba8,
292 dragging: bool,
293 window: &mut Window,
294) {
295 let thickness = if dragging { px(2.0) } else { px(1.0) };
296 let visual = if handle.moves_horizontal() && handle.moves_vertical() {
297 let side = if dragging { px(7.0) } else { px(5.0) };
298 Bounds::new(
299 point(
300 bounds.origin.x + (bounds.size.width - side) / 2.0,
301 bounds.origin.y + (bounds.size.height - side) / 2.0,
302 ),
303 size(side, side),
304 )
305 } else if handle.moves_horizontal() {
306 Bounds::new(
307 point(
308 bounds.origin.x + (bounds.size.width - thickness) / 2.0,
309 bounds.origin.y + px(4.0),
310 ),
311 size(thickness, (bounds.size.height - px(8.0)).max(px(1.0))),
312 )
313 } else {
314 Bounds::new(
315 point(
316 bounds.origin.x + px(4.0),
317 bounds.origin.y + (bounds.size.height - thickness) / 2.0,
318 ),
319 size((bounds.size.width - px(8.0)).max(px(1.0)), thickness),
320 )
321 };
322 window.paint_quad(fill(visual, rgba(color.as_rgba_hex())));
323}
324
325#[allow(clippy::too_many_lines)]
326fn register_pointer_listeners(
327 prepaint: &ResizablePrepaint,
328 config: &ResizableConfig,
329 events: &PrimitiveContext,
330 window: &mut Window,
331) {
332 let view = window.current_view();
333 let hitbox = prepaint.hitbox.clone();
334 let down_config = config.clone();
335 let down_events = events.clone();
336 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
337 if phase != DispatchPhase::Bubble
338 || event.button != MouseButton::Left
339 || down_config.disabled
340 || !hitbox.is_hovered(window)
341 {
342 return;
343 }
344 let Some(boundary) = down_events.element_bounds(&down_config.boundary_ref, cx) else {
345 return;
346 };
347 if down_config.constraints.contain
348 && !rect_within_boundary(down_config.source, (boundary.width, boundary.height))
349 {
350 return;
351 }
352 if let Some(focus) = down_config.focus.as_ref() {
353 focus.focus(window, cx);
354 }
355 let boundary_size = (boundary.width, boundary.height);
356 let update_config = down_config.clone();
357 let update_events = down_events.clone();
358 let update =
359 move |gesture: crate::interaction::GestureUpdate, _: &mut Window, cx: &mut App| {
360 let Some(boundary) = update_events.element_bounds(&update_config.boundary_ref, cx)
361 else {
362 return crate::interaction::InteractionFlow::Cancel;
363 };
364 if (boundary.width - boundary_size.0).abs() > 0.5
365 || (boundary.height - boundary_size.1).abs() > 0.5
366 {
367 return crate::interaction::InteractionFlow::Cancel;
368 }
369 if gesture.moved() {
370 let (dx, dy) = gesture.delta();
371 let rect = resize_rect(
372 update_config.source,
373 update_config.handle,
374 dx,
375 dy,
376 update_config.constraints,
377 boundary_size,
378 );
379 write_preview(&update_events, &update_config, Some(rect), cx);
380 }
381 crate::interaction::InteractionFlow::Continue
382 };
383 let finish_config = down_config.clone();
384 let finish_events = down_events.clone();
385 let finish =
386 move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
387 clear_preview(&finish_events, &finish_config, window, cx);
388 if !gesture.moved() {
389 return;
390 }
391 let boundary_unchanged = finish_events
392 .element_bounds(&finish_config.boundary_ref, cx)
393 .is_some_and(|boundary| {
394 (boundary.width - boundary_size.0).abs() <= 0.5
395 && (boundary.height - boundary_size.1).abs() <= 0.5
396 });
397 if !boundary_unchanged {
398 return;
399 }
400 let (dx, dy) = gesture.delta();
401 let rect = resize_rect(
402 finish_config.source,
403 finish_config.handle,
404 dx,
405 dy,
406 finish_config.constraints,
407 boundary_size,
408 );
409 finish_events.propose(
410 "resize",
411 resize_payload(rect, finish_config.handle),
412 window,
413 cx,
414 );
415 };
416 let cancel_config = down_config.clone();
417 let cancel_events = down_events.clone();
418 let cancel = move |window: &mut Window, cx: &mut App| {
419 clear_preview(&cancel_events, &cancel_config, window, cx);
420 };
421 let owner = down_events.interaction_owner(&down_config.id);
422 down_events.begin_interaction(
423 crate::interaction::NativeGesture::new(
424 owner,
425 event.position,
426 view,
427 update,
428 finish,
429 cancel,
430 ),
431 window,
432 cx,
433 );
434 cx.stop_propagation();
435 });
436}
437
438fn clear_preview(
439 events: &PrimitiveContext,
440 config: &ResizableConfig,
441 window: &mut Window,
442 cx: &mut App,
443) {
444 let events = events.clone();
445 let config = config.clone();
446 window.defer(cx, move |_, cx| write_preview(&events, &config, None, cx));
447}
448
449fn write_preview(
450 events: &PrimitiveContext,
451 config: &ResizableConfig,
452 rect: Option<ResizeRect>,
453 cx: &mut App,
454) {
455 let x = rect.map(|rect| rect.x - config.source.x);
456 let y = rect.map(|rect| rect.y - config.source.y);
457 let width = rect.map(|rect| rect.width);
458 let height = rect.map(|rect| rect.height);
459 let _ = events.write_signals(
460 [
461 (config.x_signal.clone(), SignalValue::OptionalFloat(x)),
462 (config.y_signal.clone(), SignalValue::OptionalFloat(y)),
463 (
464 config.width_signal.clone(),
465 SignalValue::OptionalFloat(width),
466 ),
467 (
468 config.height_signal.clone(),
469 SignalValue::OptionalFloat(height),
470 ),
471 ],
472 cx,
473 );
474}
475
476fn keyboard_delta(handle: ResizeHandle, key: &str, step: f64) -> Option<(f64, f64)> {
477 match key {
478 "left" if handle.moves_horizontal() => Some((-step, 0.0)),
479 "right" if handle.moves_horizontal() => Some((step, 0.0)),
480 "up" if handle.moves_vertical() => Some((0.0, -step)),
481 "down" if handle.moves_vertical() => Some((0.0, step)),
482 _ => None,
483 }
484}
485
486fn resize_rect(
487 source: ResizeRect,
488 handle: ResizeHandle,
489 dx: f64,
490 dy: f64,
491 constraints: ResizeConstraints,
492 boundary: (f64, f64),
493) -> ResizeRect {
494 if let Some(aspect_ratio) = constraints.aspect_ratio {
495 resize_rect_aspect(source, handle, dx, dy, constraints, boundary, aspect_ratio)
496 } else {
497 resize_rect_free(source, handle, dx, dy, constraints, boundary)
498 }
499}
500
501fn resize_rect_free(
502 source: ResizeRect,
503 handle: ResizeHandle,
504 dx: f64,
505 dy: f64,
506 constraints: ResizeConstraints,
507 boundary: (f64, f64),
508) -> ResizeRect {
509 let mut rect = source;
510 if handle.moves_west() {
511 let maximum = horizontal_max(source, handle, constraints, boundary.0);
512 rect.width = clamp_dimension(source.width - dx, constraints.min_width, maximum);
513 rect.x = source.right() - rect.width;
514 } else if handle.moves_east() {
515 let maximum = horizontal_max(source, handle, constraints, boundary.0);
516 rect.width = clamp_dimension(source.width + dx, constraints.min_width, maximum);
517 }
518 if handle.moves_north() {
519 let maximum = vertical_max(source, handle, constraints, boundary.1);
520 rect.height = clamp_dimension(source.height - dy, constraints.min_height, maximum);
521 rect.y = source.bottom() - rect.height;
522 } else if handle.moves_south() {
523 let maximum = vertical_max(source, handle, constraints, boundary.1);
524 rect.height = clamp_dimension(source.height + dy, constraints.min_height, maximum);
525 }
526 rect
527}
528
529fn resize_rect_aspect(
530 source: ResizeRect,
531 handle: ResizeHandle,
532 dx: f64,
533 dy: f64,
534 constraints: ResizeConstraints,
535 boundary: (f64, f64),
536 aspect_ratio: f64,
537) -> ResizeRect {
538 let raw_width = if handle.moves_west() {
539 source.width - dx
540 } else if handle.moves_east() {
541 source.width + dx
542 } else {
543 source.width
544 };
545 let raw_height = if handle.moves_north() {
546 source.height - dy
547 } else if handle.moves_south() {
548 source.height + dy
549 } else {
550 source.height
551 };
552 let desired_width = if handle.moves_horizontal() && handle.moves_vertical() {
553 let horizontal_intent = (raw_width - source.width).abs();
554 let vertical_intent = (raw_height * aspect_ratio - source.width).abs();
555 if horizontal_intent >= vertical_intent {
556 raw_width
557 } else {
558 raw_height * aspect_ratio
559 }
560 } else if handle.moves_horizontal() {
561 raw_width
562 } else {
563 raw_height * aspect_ratio
564 };
565 let max_width = horizontal_max(source, handle, constraints, boundary.0)
566 .min(vertical_max(source, handle, constraints, boundary.1) * aspect_ratio);
567 let min_width = constraints
568 .min_width
569 .max(constraints.min_height * aspect_ratio)
570 .min(max_width);
571 let width = desired_width.clamp(min_width, max_width);
572 let height = width / aspect_ratio;
573 let x = if handle.moves_west() {
574 source.right() - width
575 } else if handle.moves_east() {
576 source.x
577 } else {
578 source.x + (source.width - width) / 2.0
579 };
580 let y = if handle.moves_north() {
581 source.bottom() - height
582 } else if handle.moves_south() {
583 source.y
584 } else {
585 source.y + (source.height - height) / 2.0
586 };
587 ResizeRect {
588 x,
589 y,
590 width,
591 height,
592 }
593}
594
595fn horizontal_max(
596 source: ResizeRect,
597 handle: ResizeHandle,
598 constraints: ResizeConstraints,
599 boundary_width: f64,
600) -> f64 {
601 if !constraints.contain {
602 return constraints.max_width;
603 }
604 let boundary_max = if handle.moves_west() {
605 source.right().max(0.0)
606 } else if handle.moves_east() {
607 (boundary_width - source.x).max(0.0)
608 } else {
609 let center = source.x + source.width / 2.0;
610 (2.0 * center.min((boundary_width - center).max(0.0))).max(0.0)
611 };
612 constraints.max_width.min(boundary_max)
613}
614
615fn vertical_max(
616 source: ResizeRect,
617 handle: ResizeHandle,
618 constraints: ResizeConstraints,
619 boundary_height: f64,
620) -> f64 {
621 if !constraints.contain {
622 return constraints.max_height;
623 }
624 let boundary_max = if handle.moves_north() {
625 source.bottom().max(0.0)
626 } else if handle.moves_south() {
627 (boundary_height - source.y).max(0.0)
628 } else {
629 let center = source.y + source.height / 2.0;
630 (2.0 * center.min((boundary_height - center).max(0.0))).max(0.0)
631 };
632 constraints.max_height.min(boundary_max)
633}
634
635fn clamp_dimension(value: f64, minimum: f64, maximum: f64) -> f64 {
636 value.clamp(minimum.min(maximum), maximum)
637}
638
639fn rect_within_boundary(rect: ResizeRect, boundary: (f64, f64)) -> bool {
640 rect.x >= 0.0
641 && rect.y >= 0.0
642 && rect.right() <= boundary.0 + 0.5
643 && rect.bottom() <= boundary.1 + 0.5
644}
645
646fn resize_payload(rect: ResizeRect, handle: ResizeHandle) -> UiValue {
647 UiValue::Map(BTreeMap::from([
648 ("x".to_owned(), UiValue::Float(rect.x)),
649 ("y".to_owned(), UiValue::Float(rect.y)),
650 ("width".to_owned(), UiValue::Float(rect.width)),
651 ("height".to_owned(), UiValue::Float(rect.height)),
652 (
653 "handle".to_owned(),
654 UiValue::String(handle.as_str().to_owned()),
655 ),
656 ]))
657}
658
659#[derive(Default)]
660pub struct ResizablePrimitiveHandler {
661 controls: BTreeMap<PrimitiveInstanceId, (ResizableConfig, PrimitiveContext)>,
662}
663
664fn keyboard_resize_payload(config: &ResizableConfig, key: &str, shift: bool) -> Option<UiValue> {
665 if config.disabled {
666 return None;
667 }
668 let step = if shift {
669 config.keyboard_step * 4.0
670 } else {
671 config.keyboard_step
672 };
673 let (dx, dy) = keyboard_delta(config.handle, key, step)?;
674 let boundary = config.boundary_size.get()?;
675 if config.constraints.contain && !rect_within_boundary(config.source, boundary) {
676 return None;
677 }
678 let rect = resize_rect(
679 config.source,
680 config.handle,
681 dx,
682 dy,
683 config.constraints,
684 boundary,
685 );
686 Some(resize_payload(rect, config.handle))
687}
688
689impl PrimitiveHandler for ResizablePrimitiveHandler {
690 fn uses_primary_focus(&self) -> bool {
691 true
692 }
693
694 fn render(
695 &mut self,
696 instance: &PrimitiveInstance,
697 events: &PrimitiveContext,
698 theme: &PrimitiveTheme,
699 _: &mut Window,
700 _: &mut App,
701 ) -> Result<AnyElement, String> {
702 let id = instance
703 .id
704 .clone()
705 .ok_or_else(|| "ResizableHandlePrimitive requires a stable key".to_owned())?;
706 let config = parse_config(
707 &instance.node.props,
708 instance.focus_handle().cloned(),
709 theme,
710 )?;
711 let key_config = config.clone();
712 let key_events = events.clone();
713 self.controls.insert(id, (config.clone(), events.clone()));
714 let mut root = div().size_full();
715 if let Some(focus) = config.focus.as_ref() {
716 root = root.track_focus(&focus.clone().tab_stop(!config.disabled));
717 }
718 Ok(root
719 .on_key_down(move |event: &KeyDownEvent, window, cx| {
720 if let Some(payload) = keyboard_resize_payload(
721 &key_config,
722 event.keystroke.key.as_str(),
723 event.keystroke.modifiers.shift,
724 ) {
725 key_events.propose("resize", payload, window, cx);
726 cx.stop_propagation();
727 }
728 })
729 .child(ResizableHandleElement {
730 config,
731 events: events.clone(),
732 })
733 .into_any_element())
734 }
735
736 fn perform_key(
737 &mut self,
738 instance: &PrimitiveInstanceId,
739 key: &str,
740 ) -> Result<Option<crate::primitive::PrimitiveSemanticProposal>, String> {
741 let Some((config, events)) = self.controls.get(instance).cloned() else {
742 return Ok(None);
743 };
744 let Some(payload) = keyboard_resize_payload(&config, key, false) else {
745 return Ok(None);
746 };
747 events
748 .prepare_proposal("resize", payload)
749 .map(Some)
750 .map_err(|error| error.to_string())
751 }
752
753 fn unmount(&mut self, instance: &PrimitiveInstanceId) {
754 self.controls.remove(instance);
755 }
756}
757
758fn parse_config(
759 props: &PrimitiveProps,
760 focus: Option<FocusHandle>,
761 theme: &PrimitiveTheme,
762) -> Result<ResizableConfig, String> {
763 let handle = props
764 .string("handle")
765 .and_then(ResizeHandle::parse)
766 .ok_or_else(|| "resizable handle must be n, s, e, w, ne, nw, se, or sw".to_owned())?;
767 let source = ResizeRect {
768 x: required_number(props, "x")?,
769 y: required_number(props, "y")?,
770 width: required_number(props, "width")?,
771 height: required_number(props, "height")?,
772 };
773 let constraints = ResizeConstraints {
774 min_width: props.number("min_width").unwrap_or(24.0),
775 min_height: props.number("min_height").unwrap_or(24.0),
776 max_width: props.number("max_width").unwrap_or(MAX_RESIZE_DIMENSION),
777 max_height: props.number("max_height").unwrap_or(MAX_RESIZE_DIMENSION),
778 aspect_ratio: optional_number_prop(props, "aspect_ratio")?,
779 contain: props.boolean("contain").unwrap_or(true),
780 };
781 validate_geometry(source, constraints)?;
782 let x_signal = required_optional_float_signal(props, "x_signal")?;
783 let y_signal = required_optional_float_signal(props, "y_signal")?;
784 let width_signal = required_optional_float_signal(props, "width_signal")?;
785 let height_signal = required_optional_float_signal(props, "height_signal")?;
786 let keyboard_step = props.number("keyboard_step").unwrap_or(8.0);
787 if !keyboard_step.is_finite() || !(0.0..=512.0).contains(&keyboard_step) || keyboard_step == 0.0
788 {
789 return Err("resizable keyboard_step must be finite and in (0, 512]".to_owned());
790 }
791 Ok(ResizableConfig {
792 id: format!(
793 "gpui-rhai-resizable:{}:{}:{handle:?}",
794 width_signal.id().component(),
795 width_signal.id().key()
796 ),
797 handle,
798 source,
799 constraints,
800 boundary_ref: props
801 .element_ref("boundary_ref")
802 .cloned()
803 .ok_or_else(|| "resizable requires boundary_ref".to_owned())?,
804 boundary_size: Rc::new(Cell::new(None)),
805 x_signal,
806 y_signal,
807 width_signal,
808 height_signal,
809 keyboard_step,
810 disabled: props.boolean("disabled").unwrap_or(false),
811 idle_color: theme
812 .color("border")
813 .unwrap_or(Rgba8::from_rgba_hex(0x5555_55ff)),
814 active_color: theme
815 .color("accent")
816 .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
817 focus,
818 })
819}
820
821fn validate_geometry(source: ResizeRect, constraints: ResizeConstraints) -> Result<(), String> {
822 let values = [
823 source.x,
824 source.y,
825 source.width,
826 source.height,
827 constraints.min_width,
828 constraints.min_height,
829 constraints.max_width,
830 constraints.max_height,
831 ];
832 if values.iter().any(|value| !value.is_finite())
833 || source.width <= 0.0
834 || source.height <= 0.0
835 || constraints.min_width <= 0.0
836 || constraints.min_height <= 0.0
837 || constraints.max_width < constraints.min_width
838 || constraints.max_height < constraints.min_height
839 || constraints.max_width > MAX_RESIZE_DIMENSION
840 || constraints.max_height > MAX_RESIZE_DIMENSION
841 || constraints
842 .aspect_ratio
843 .is_some_and(|ratio| !ratio.is_finite() || ratio <= 0.0)
844 {
845 return Err("resizable rectangle and constraints are invalid".to_owned());
846 }
847 Ok(())
848}
849
850fn required_number(props: &PrimitiveProps, name: &str) -> Result<f64, String> {
851 props
852 .number(name)
853 .ok_or_else(|| format!("resizable requires numeric {name}"))
854}
855
856fn optional_number_prop(props: &PrimitiveProps, name: &str) -> Result<Option<f64>, String> {
857 match props.get(name) {
858 None | Some(PrimitiveValue::Data(UiValue::Null)) => Ok(None),
859 Some(_) => props
860 .number(name)
861 .map(Some)
862 .ok_or_else(|| format!("resizable {name} must be an optional number")),
863 }
864}
865
866fn required_optional_float_signal(
867 props: &PrimitiveProps,
868 name: &str,
869) -> Result<crate::NativeSignal, String> {
870 let signal = props
871 .signal(name)
872 .cloned()
873 .ok_or_else(|| format!("resizable requires signal {name}"))?;
874 if signal.id().kind() != SignalKind::OptionalFloat {
875 return Err(format!("resizable {name} must be optional_float"));
876 }
877 Ok(signal)
878}
879
880fn rect_schema() -> ValueSchema {
881 ValueSchema::object(BTreeMap::from([
882 ("x".to_owned(), ObjectField::required(ValueSchema::number())),
883 ("y".to_owned(), ObjectField::required(ValueSchema::number())),
884 (
885 "width".to_owned(),
886 ObjectField::required(ValueSchema::positive_number()),
887 ),
888 (
889 "height".to_owned(),
890 ObjectField::required(ValueSchema::positive_number()),
891 ),
892 (
893 "handle".to_owned(),
894 ObjectField::required(ValueSchema::String {
895 allowed: vec![
896 "n".to_owned(),
897 "s".to_owned(),
898 "e".to_owned(),
899 "w".to_owned(),
900 "ne".to_owned(),
901 "nw".to_owned(),
902 "se".to_owned(),
903 "sw".to_owned(),
904 ],
905 }),
906 ),
907 ]))
908}
909
910fn bounded_dimension_schema() -> ValueSchema {
911 ValueSchema::Number {
912 min: None,
913 max: Some(MAX_RESIZE_DIMENSION),
914 exclusive_min: Some(0.0),
915 exclusive_max: None,
916 }
917}
918
919#[must_use]
925pub fn resizable_primitive_descriptor() -> PrimitiveDescriptor {
926 let optional_number = || ObjectField::optional(ValueSchema::optional(ValueSchema::number()));
927 PrimitiveDescriptor {
928 id: PrimitiveId::parse("gpui_rhai.resizable_handle").expect("static primitive ID"),
929 export: "ResizableHandlePrimitive".to_owned(),
930 props: BTreeMap::from([
931 (
932 "handle".to_owned(),
933 ObjectField::required(ValueSchema::String {
934 allowed: vec![
935 "n".to_owned(),
936 "s".to_owned(),
937 "e".to_owned(),
938 "w".to_owned(),
939 "ne".to_owned(),
940 "nw".to_owned(),
941 "se".to_owned(),
942 "sw".to_owned(),
943 ],
944 }),
945 ),
946 ("x".to_owned(), ObjectField::required(ValueSchema::number())),
947 ("y".to_owned(), ObjectField::required(ValueSchema::number())),
948 (
949 "width".to_owned(),
950 ObjectField::required(ValueSchema::positive_number()),
951 ),
952 (
953 "height".to_owned(),
954 ObjectField::required(ValueSchema::positive_number()),
955 ),
956 (
957 "min_width".to_owned(),
958 ObjectField::optional(bounded_dimension_schema()),
959 ),
960 (
961 "min_height".to_owned(),
962 ObjectField::optional(bounded_dimension_schema()),
963 ),
964 (
965 "max_width".to_owned(),
966 ObjectField::optional(bounded_dimension_schema()),
967 ),
968 (
969 "max_height".to_owned(),
970 ObjectField::optional(bounded_dimension_schema()),
971 ),
972 ("aspect_ratio".to_owned(), optional_number()),
973 (
974 "contain".to_owned(),
975 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(true)),
976 ),
977 (
978 "keyboard_step".to_owned(),
979 ObjectField::optional(ValueSchema::Number {
980 min: None,
981 max: Some(512.0),
982 exclusive_min: Some(0.0),
983 exclusive_max: None,
984 }),
985 ),
986 (
987 "disabled".to_owned(),
988 ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
989 ),
990 (
991 "boundary_ref".to_owned(),
992 ObjectField::required(ValueSchema::Ref),
993 ),
994 (
995 "x_signal".to_owned(),
996 ObjectField::required(ValueSchema::Signal),
997 ),
998 (
999 "y_signal".to_owned(),
1000 ObjectField::required(ValueSchema::Signal),
1001 ),
1002 (
1003 "width_signal".to_owned(),
1004 ObjectField::required(ValueSchema::Signal),
1005 ),
1006 (
1007 "height_signal".to_owned(),
1008 ObjectField::required(ValueSchema::Signal),
1009 ),
1010 (
1011 "on_resize".to_owned(),
1012 ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
1013 ),
1014 ]),
1015 events: BTreeMap::from([(
1016 "resize".to_owned(),
1017 EventSchema {
1018 payload: rect_schema(),
1019 },
1020 )]),
1021 state: ComponentStateSchema::default(),
1022 lifecycle: true,
1023 effect: None,
1024 }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029 use super::*;
1030
1031 fn constraints(aspect_ratio: Option<f64>) -> ResizeConstraints {
1032 ResizeConstraints {
1033 min_width: 40.0,
1034 min_height: 30.0,
1035 max_width: 500.0,
1036 max_height: 400.0,
1037 aspect_ratio,
1038 contain: true,
1039 }
1040 }
1041
1042 #[test]
1043 fn west_and_north_handles_keep_the_opposite_corner_fixed() {
1044 let source = ResizeRect {
1045 x: 100.0,
1046 y: 80.0,
1047 width: 200.0,
1048 height: 120.0,
1049 };
1050 let resized = resize_rect(
1051 source,
1052 ResizeHandle::NorthWest,
1053 -30.0,
1054 -20.0,
1055 constraints(None),
1056 (600.0, 500.0),
1057 );
1058 assert!((resized.x - 70.0).abs() < f64::EPSILON);
1059 assert!((resized.y - 60.0).abs() < f64::EPSILON);
1060 assert!((resized.right() - source.right()).abs() < f64::EPSILON);
1061 assert!((resized.bottom() - source.bottom()).abs() < f64::EPSILON);
1062 let east_boundary = resize_rect(
1063 source,
1064 ResizeHandle::East,
1065 500.0,
1066 0.0,
1067 constraints(None),
1068 (350.0, 500.0),
1069 );
1070 assert!((east_boundary.width - 250.0).abs() < f64::EPSILON);
1071 let west_boundary = resize_rect(
1072 source,
1073 ResizeHandle::West,
1074 -500.0,
1075 0.0,
1076 constraints(None),
1077 (600.0, 500.0),
1078 );
1079 assert!((west_boundary.x - 0.0).abs() < f64::EPSILON);
1080 assert!((west_boundary.width - 300.0).abs() < f64::EPSILON);
1081 }
1082
1083 #[test]
1084 fn containment_and_aspect_ratio_clamp_one_atomic_rect() {
1085 let source = ResizeRect {
1086 x: 120.0,
1087 y: 90.0,
1088 width: 160.0,
1089 height: 90.0,
1090 };
1091 let resized = resize_rect(
1092 source,
1093 ResizeHandle::SouthEast,
1094 500.0,
1095 500.0,
1096 constraints(Some(16.0 / 9.0)),
1097 (420.0, 300.0),
1098 );
1099 assert!(resized.right() <= 420.0 + f64::EPSILON);
1100 assert!(resized.bottom() <= 300.0 + f64::EPSILON);
1101 assert!((resized.width / resized.height - 16.0 / 9.0).abs() < 1e-9);
1102 }
1103}