Skip to main content

gpui_rhai/
split_resize.rs

1//! Native hot-lane resize handle shared by the public `SplitPane` component.
2
3use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::rc::Rc;
6
7use gpui::{
8    AnyElement, App, Bounds, CursorStyle, DispatchPhase, Element, ElementId, GlobalElementId,
9    Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseButton, MouseDownEvent,
10    Pixels, Point, Style, Window, fill, point, px, relative, rgba, size,
11};
12
13use crate::{
14    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
15    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveProps, PrimitiveTheme, Rgba8,
16    SignalKind, SignalValue, TextDirection, UiValue, ValueSchema,
17};
18
19const MAX_PANEL_SIZE: f64 = 16_384.0;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22enum SplitOrientation {
23    Horizontal,
24    Vertical,
25}
26
27#[derive(Clone)]
28struct SplitResizeConfig {
29    id: String,
30    orientation: SplitOrientation,
31    source_ratio: f64,
32    min_start: f64,
33    min_end: f64,
34    max_start: f64,
35    disabled: bool,
36    signal: crate::NativeSignal,
37    group_ref: crate::ElementRef,
38    start_ref: crate::ElementRef,
39    direction: TextDirection,
40    idle_color: Rgba8,
41    active_color: Rgba8,
42}
43
44#[derive(Clone)]
45struct SplitResizeState(Rc<RefCell<SplitResizeStateInner>>);
46
47#[derive(Clone)]
48struct SplitResizeStateInner {
49    source_ratio: f64,
50    signal: crate::SignalId,
51    constraint_preview: Option<f64>,
52}
53
54impl SplitResizeState {
55    fn new(config: &SplitResizeConfig) -> Self {
56        Self(Rc::new(RefCell::new(SplitResizeStateInner {
57            source_ratio: config.source_ratio,
58            signal: config.signal.id().clone(),
59            constraint_preview: None,
60        })))
61    }
62}
63
64struct SplitResizePrepaint {
65    hitbox: Hitbox,
66}
67
68struct SplitResizeHandle {
69    config: SplitResizeConfig,
70    events: PrimitiveContext,
71}
72
73impl IntoElement for SplitResizeHandle {
74    type Element = Self;
75
76    fn into_element(self) -> Self::Element {
77        self
78    }
79}
80
81impl Element for SplitResizeHandle {
82    type RequestLayoutState = ();
83    type PrepaintState = SplitResizePrepaint;
84
85    fn id(&self) -> Option<ElementId> {
86        Some(ElementId::Name(self.config.id.clone().into()))
87    }
88
89    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
90        None
91    }
92
93    fn request_layout(
94        &mut self,
95        _: Option<&GlobalElementId>,
96        _: Option<&InspectorElementId>,
97        window: &mut Window,
98        cx: &mut App,
99    ) -> (LayoutId, Self::RequestLayoutState) {
100        (
101            window.request_layout(
102                Style {
103                    size: size(relative(1.0).into(), relative(1.0).into()),
104                    ..Style::default()
105                },
106                None,
107                cx,
108            ),
109            (),
110        )
111    }
112
113    fn prepaint(
114        &mut self,
115        _: Option<&GlobalElementId>,
116        _: Option<&InspectorElementId>,
117        bounds: Bounds<Pixels>,
118        (): &mut Self::RequestLayoutState,
119        window: &mut Window,
120        cx: &mut App,
121    ) -> Self::PrepaintState {
122        let state = window
123            .use_state(cx, |_, _| SplitResizeState::new(&self.config))
124            .read(cx)
125            .clone();
126        let reset = {
127            let mut inner = state.0.borrow_mut();
128            let changed = (inner.source_ratio - self.config.source_ratio).abs() > f64::EPSILON
129                || inner.signal != *self.config.signal.id();
130            if changed {
131                inner.source_ratio = self.config.source_ratio;
132                inner.signal = self.config.signal.id().clone();
133                inner.constraint_preview = None;
134            }
135            changed
136        };
137        let owner = self.events.interaction_owner(&self.config.id);
138        if reset {
139            self.events.cancel_interaction(&owner, window, cx);
140        }
141        let dragging = self.events.interaction_is_active(&owner);
142        if !dragging {
143            let constrained = self
144                .events
145                .element_bounds(&self.config.group_ref, cx)
146                .and_then(|group| {
147                    let group_size = axis_size(group, self.config.orientation);
148                    let handle_size = match self.config.orientation {
149                        SplitOrientation::Horizontal => f64::from(bounds.size.width),
150                        SplitOrientation::Vertical => f64::from(bounds.size.height),
151                    };
152                    let requested = self.config.source_ratio * group_size;
153                    let legal = clamp_start_size(requested, group_size, handle_size, &self.config);
154                    ((legal - requested).abs() > 0.5).then_some(legal)
155                });
156            let changed = {
157                let mut inner = state.0.borrow_mut();
158                let changed = !same_optional_float(inner.constraint_preview, constrained);
159                inner.constraint_preview = constrained;
160                changed
161            };
162            if reset || changed {
163                write_preview(&self.events, &self.config.signal, constrained, window, cx);
164            }
165        }
166        SplitResizePrepaint {
167            hitbox: window.insert_hitbox(bounds, HitboxBehavior::BlockMouseExceptScroll),
168        }
169    }
170
171    fn paint(
172        &mut self,
173        _: Option<&GlobalElementId>,
174        _: Option<&InspectorElementId>,
175        bounds: Bounds<Pixels>,
176        (): &mut Self::RequestLayoutState,
177        prepaint: &mut Self::PrepaintState,
178        window: &mut Window,
179        _: &mut App,
180    ) {
181        let owner = self.events.interaction_owner(&self.config.id);
182        self.events.present_interaction(owner.clone());
183        let dragged = self.events.interaction_is_active(&owner);
184        let hovered = !self.config.disabled && prepaint.hitbox.is_hovered(window);
185        let color = if dragged || hovered {
186            self.config.active_color
187        } else {
188            self.config.idle_color
189        };
190        let thickness = if dragged { px(2.0) } else { px(1.0) };
191        let line = match self.config.orientation {
192            SplitOrientation::Horizontal => Bounds::new(
193                point(
194                    bounds.origin.x + (bounds.size.width - thickness) / 2.0,
195                    bounds.origin.y + px(4.0),
196                ),
197                size(thickness, (bounds.size.height - px(8.0)).max(px(1.0))),
198            ),
199            SplitOrientation::Vertical => Bounds::new(
200                point(
201                    bounds.origin.x + px(4.0),
202                    bounds.origin.y + (bounds.size.height - thickness) / 2.0,
203                ),
204                size((bounds.size.width - px(8.0)).max(px(1.0)), thickness),
205            ),
206        };
207        window.paint_quad(fill(line, rgba(color.as_rgba_hex())));
208        if !self.config.disabled {
209            window.set_cursor_style(
210                match self.config.orientation {
211                    SplitOrientation::Horizontal => CursorStyle::ResizeColumn,
212                    SplitOrientation::Vertical => CursorStyle::ResizeRow,
213                },
214                &prepaint.hitbox,
215            );
216        }
217        register_pointer_listeners(prepaint, bounds, &self.config, &self.events, window);
218    }
219}
220
221#[allow(clippy::too_many_lines)]
222fn register_pointer_listeners(
223    prepaint: &SplitResizePrepaint,
224    handle_bounds: Bounds<Pixels>,
225    config: &SplitResizeConfig,
226    events: &PrimitiveContext,
227    window: &mut Window,
228) {
229    let view = window.current_view();
230    let hitbox = prepaint.hitbox.clone();
231    let down_config = config.clone();
232    let down_events = events.clone();
233    window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
234        if phase != DispatchPhase::Bubble
235            || event.button != MouseButton::Left
236            || down_config.disabled
237            || !hitbox.is_hovered(window)
238        {
239            return;
240        }
241        let Some(group) = down_events.element_bounds(&down_config.group_ref, cx) else {
242            return;
243        };
244        let Some(start) = down_events.element_bounds(&down_config.start_ref, cx) else {
245            return;
246        };
247        let start_size = axis_size(start, down_config.orientation);
248        let group_size = axis_size(group, down_config.orientation);
249        let handle_size = match down_config.orientation {
250            SplitOrientation::Horizontal => f64::from(handle_bounds.size.width),
251            SplitOrientation::Vertical => f64::from(handle_bounds.size.height),
252        };
253        let update_config = down_config.clone();
254        let update_events = down_events.clone();
255        let update =
256            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
257                if !gesture.moved() {
258                    return crate::interaction::InteractionFlow::Continue;
259                }
260                let delta = axis_delta(
261                    gesture.start(),
262                    gesture.current(),
263                    update_config.orientation,
264                    update_config.direction,
265                );
266                let size =
267                    clamp_start_size(start_size + delta, group_size, handle_size, &update_config);
268                write_preview(
269                    &update_events,
270                    &update_config.signal,
271                    Some(size),
272                    window,
273                    cx,
274                );
275                crate::interaction::InteractionFlow::Continue
276            };
277        let finish_config = down_config.clone();
278        let finish_events = down_events.clone();
279        let finish =
280            move |gesture: crate::interaction::GestureUpdate, window: &mut Window, cx: &mut App| {
281                clear_preview(&finish_events, &finish_config.signal, window, cx);
282                if !gesture.moved() || group_size <= 0.0 {
283                    return;
284                }
285                let delta = axis_delta(
286                    gesture.start(),
287                    gesture.current(),
288                    finish_config.orientation,
289                    finish_config.direction,
290                );
291                let size =
292                    clamp_start_size(start_size + delta, group_size, handle_size, &finish_config);
293                finish_events.propose(
294                    "resize",
295                    UiValue::Float((size / group_size).clamp(0.0, 1.0)),
296                    window,
297                    cx,
298                );
299            };
300        let cancel_signal = down_config.signal.clone();
301        let cancel_events = down_events.clone();
302        let cancel = move |window: &mut Window, cx: &mut App| {
303            clear_preview(&cancel_events, &cancel_signal, window, cx);
304        };
305        let owner = down_events.interaction_owner(&down_config.id);
306        down_events.begin_interaction(
307            crate::interaction::NativeGesture::new(
308                owner,
309                event.position,
310                view,
311                update,
312                finish,
313                cancel,
314            ),
315            window,
316            cx,
317        );
318        cx.stop_propagation();
319    });
320}
321
322fn clear_preview(
323    events: &PrimitiveContext,
324    signal: &crate::NativeSignal,
325    window: &mut Window,
326    cx: &mut App,
327) {
328    write_preview(events, signal, None, window, cx);
329}
330
331fn write_preview(
332    events: &PrimitiveContext,
333    signal: &crate::NativeSignal,
334    value: Option<f64>,
335    window: &mut Window,
336    cx: &mut App,
337) {
338    let events = events.clone();
339    let signal = signal.clone();
340    window.defer(cx, move |_, cx| {
341        let _ = events.write_signal(&signal, SignalValue::OptionalFloat(value), cx);
342    });
343}
344
345fn same_optional_float(left: Option<f64>, right: Option<f64>) -> bool {
346    match (left, right) {
347        (Some(left), Some(right)) => (left - right).abs() <= 0.5,
348        (None, None) => true,
349        _ => false,
350    }
351}
352
353fn axis_size(bounds: crate::GeometryBounds, orientation: SplitOrientation) -> f64 {
354    match orientation {
355        SplitOrientation::Horizontal => bounds.width,
356        SplitOrientation::Vertical => bounds.height,
357    }
358}
359
360fn axis_delta(
361    start: Point<Pixels>,
362    current: Point<Pixels>,
363    orientation: SplitOrientation,
364    direction: TextDirection,
365) -> f64 {
366    match orientation {
367        SplitOrientation::Vertical => f64::from(current.y - start.y),
368        SplitOrientation::Horizontal => {
369            let delta = f64::from(current.x - start.x);
370            match direction {
371                TextDirection::LeftToRight => delta,
372                TextDirection::RightToLeft => -delta,
373            }
374        }
375    }
376}
377
378fn clamp_start_size(
379    proposed: f64,
380    group_size: f64,
381    handle_size: f64,
382    config: &SplitResizeConfig,
383) -> f64 {
384    clamp_start_size_values(
385        proposed,
386        group_size,
387        handle_size,
388        config.min_start,
389        config.min_end,
390        config.max_start,
391    )
392}
393
394fn clamp_start_size_values(
395    proposed: f64,
396    group_size: f64,
397    handle_size: f64,
398    min_start: f64,
399    min_end: f64,
400    max_start: f64,
401) -> f64 {
402    let available = (group_size - handle_size).max(0.0);
403    let max_from_end = (available - min_end).max(0.0);
404    let max = max_start.min(max_from_end);
405    let min = min_start.min(max);
406    proposed.clamp(min, max)
407}
408
409#[derive(Default)]
410pub struct SplitResizePrimitiveHandler;
411
412impl PrimitiveHandler for SplitResizePrimitiveHandler {
413    fn render(
414        &mut self,
415        instance: &PrimitiveInstance,
416        events: &PrimitiveContext,
417        theme: &PrimitiveTheme,
418        _: &mut Window,
419        _: &mut App,
420    ) -> Result<AnyElement, String> {
421        Ok(SplitResizeHandle {
422            config: parse_config(&instance.node.props, theme)?,
423            events: events.clone(),
424        }
425        .into_any_element())
426    }
427}
428
429fn parse_config(
430    props: &PrimitiveProps,
431    theme: &PrimitiveTheme,
432) -> Result<SplitResizeConfig, String> {
433    let orientation = match props.string("orientation") {
434        Some("horizontal") => SplitOrientation::Horizontal,
435        Some("vertical") => SplitOrientation::Vertical,
436        _ => return Err("split resize orientation must be horizontal or vertical".to_owned()),
437    };
438    let source_ratio = props
439        .number("source_ratio")
440        .ok_or_else(|| "split resize requires source_ratio".to_owned())?;
441    let min_start = props.number("min_start").unwrap_or(0.0);
442    let min_end = props.number("min_end").unwrap_or(0.0);
443    let max_start = props.number("max_start").unwrap_or(MAX_PANEL_SIZE);
444    let disabled = props.boolean("disabled").unwrap_or(false);
445    if !source_ratio.is_finite()
446        || !(0.0..=1.0).contains(&source_ratio)
447        || !min_start.is_finite()
448        || !min_end.is_finite()
449        || !max_start.is_finite()
450        || min_start < 0.0
451        || min_end < 0.0
452        || max_start < min_start
453        || max_start > MAX_PANEL_SIZE
454    {
455        return Err("split resize bounds and ratio are invalid".to_owned());
456    }
457    let signal = props
458        .signal("signal")
459        .cloned()
460        .ok_or_else(|| "split resize requires signal".to_owned())?;
461    if signal.id().kind() != SignalKind::OptionalFloat {
462        return Err("split resize signal must be optional_float".to_owned());
463    }
464    let group_ref = props
465        .element_ref("group_ref")
466        .cloned()
467        .ok_or_else(|| "split resize requires group_ref".to_owned())?;
468    let start_ref = props
469        .element_ref("start_ref")
470        .cloned()
471        .ok_or_else(|| "split resize requires start_ref".to_owned())?;
472    Ok(SplitResizeConfig {
473        id: format!(
474            "gpui-rhai-split-resize:{}:{}",
475            signal.id().component(),
476            signal.id().key()
477        ),
478        orientation,
479        source_ratio,
480        min_start,
481        min_end,
482        max_start,
483        disabled,
484        signal,
485        group_ref,
486        start_ref,
487        direction: theme.direction(),
488        idle_color: theme
489            .color("border")
490            .unwrap_or(Rgba8::from_rgba_hex(0x5555_55ff)),
491        active_color: theme
492            .color("accent")
493            .unwrap_or(Rgba8::from_rgba_hex(0x3b82_f6ff)),
494    })
495}
496
497fn panel_bound_schema() -> ValueSchema {
498    ValueSchema::Number {
499        min: Some(0.0),
500        max: Some(MAX_PANEL_SIZE),
501        exclusive_min: None,
502        exclusive_max: None,
503    }
504}
505
506/// Build the native `SplitPane` drag-handle schema.
507///
508/// # Panics
509///
510/// Panics only if the static built-in primitive ID becomes invalid.
511#[must_use]
512pub fn split_resize_primitive_descriptor() -> PrimitiveDescriptor {
513    PrimitiveDescriptor {
514        id: PrimitiveId::parse("gpui_rhai.split_resize").expect("static primitive ID"),
515        export: "SplitResizePrimitive".to_owned(),
516        props: BTreeMap::from([
517            (
518                "orientation".to_owned(),
519                ObjectField::required(ValueSchema::String {
520                    allowed: vec!["horizontal".to_owned(), "vertical".to_owned()],
521                }),
522            ),
523            (
524                "source_ratio".to_owned(),
525                ObjectField::required(ValueSchema::Number {
526                    min: Some(0.0),
527                    max: Some(1.0),
528                    exclusive_min: None,
529                    exclusive_max: None,
530                }),
531            ),
532            (
533                "min_start".to_owned(),
534                ObjectField::optional(panel_bound_schema()),
535            ),
536            (
537                "min_end".to_owned(),
538                ObjectField::optional(panel_bound_schema()),
539            ),
540            (
541                "max_start".to_owned(),
542                ObjectField::optional(panel_bound_schema()),
543            ),
544            (
545                "disabled".to_owned(),
546                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
547            ),
548            (
549                "signal".to_owned(),
550                ObjectField::required(ValueSchema::Signal),
551            ),
552            (
553                "group_ref".to_owned(),
554                ObjectField::required(ValueSchema::Ref),
555            ),
556            (
557                "start_ref".to_owned(),
558                ObjectField::required(ValueSchema::Ref),
559            ),
560            (
561                "on_resize".to_owned(),
562                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
563            ),
564        ]),
565        events: BTreeMap::from([(
566            "resize".to_owned(),
567            EventSchema {
568                payload: ValueSchema::Number {
569                    min: Some(0.0),
570                    max: Some(1.0),
571                    exclusive_min: None,
572                    exclusive_max: None,
573                },
574            },
575        )]),
576        state: ComponentStateSchema::default(),
577        lifecycle: false,
578        effect: None,
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn split_delta_and_constraints_are_directional_and_deterministic() {
588        let start = point(px(100.0), px(100.0));
589        let moved = point(px(132.0), px(118.0));
590        let ltr = axis_delta(
591            start,
592            moved,
593            SplitOrientation::Horizontal,
594            TextDirection::LeftToRight,
595        );
596        let rtl = axis_delta(
597            start,
598            moved,
599            SplitOrientation::Horizontal,
600            TextDirection::RightToLeft,
601        );
602        let vertical = axis_delta(
603            start,
604            moved,
605            SplitOrientation::Vertical,
606            TextDirection::RightToLeft,
607        );
608        assert!((ltr - 32.0).abs() < f64::EPSILON);
609        assert!((rtl + 32.0).abs() < f64::EPSILON);
610        assert!((vertical - 18.0).abs() < f64::EPSILON);
611        let maximum = clamp_start_size_values(450.0, 500.0, 8.0, 100.0, 120.0, 360.0);
612        let minimum = clamp_start_size_values(20.0, 500.0, 8.0, 100.0, 120.0, 360.0);
613        let overconstrained = clamp_start_size_values(100.0, 180.0, 8.0, 120.0, 120.0, 360.0);
614        assert!((maximum - 360.0).abs() < f64::EPSILON);
615        assert!((minimum - 100.0).abs() < f64::EPSILON);
616        assert!(
617            (overconstrained - 52.0).abs() < f64::EPSILON,
618            "when minima exceed available space, the end-panel minimum wins deterministically"
619        );
620    }
621}