Skip to main content

gpui_rhai/
resizable.rs

1//! Native hot-lane edge and corner handles for the public `Resizable` component.
2
3use 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/// Build the native `Resizable` edge/corner primitive schema.
920///
921/// # Panics
922///
923/// Panics only if the static built-in primitive ID becomes invalid.
924#[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}