Skip to main content

gpui_base/resizable/
mod.rs

1use std::ops::Range;
2
3use gpui::{
4    Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
5};
6
7mod panel;
8mod resize_handle;
9pub use panel::*;
10#[doc(hidden)]
11pub use resize_handle::*;
12
13#[doc(hidden)]
14pub const PANEL_MIN_SIZE: Pixels = px(100.);
15
16/// Create a [`ResizablePanelGroup`] with horizontal resizing
17pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
18    ResizablePanelGroup::new(id).axis(Axis::Horizontal)
19}
20
21/// Create a [`ResizablePanelGroup`] with vertical resizing
22pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
23    ResizablePanelGroup::new(id).axis(Axis::Vertical)
24}
25
26/// Create a [`ResizablePanel`].
27pub fn resizable_panel() -> ResizablePanel {
28    ResizablePanel::new()
29}
30
31/// State for a [`ResizablePanel`]
32#[derive(Debug, Clone)]
33pub struct ResizableState {
34    /// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
35    axis: Axis,
36    panels: Vec<ResizablePanelState>,
37    sizes: Vec<Pixels>,
38    resizing_panel_ix: Option<usize>,
39    bounds: Bounds<Pixels>,
40}
41
42impl Default for ResizableState {
43    fn default() -> Self {
44        Self {
45            axis: Axis::Horizontal,
46            panels: vec![],
47            sizes: vec![],
48            resizing_panel_ix: None,
49            bounds: Bounds::default(),
50        }
51    }
52}
53
54impl ResizableState {
55    /// Get the size of the panels.
56    pub fn sizes(&self) -> &Vec<Pixels> {
57        &self.sizes
58    }
59
60    /// Programmatically resize the panel at `ix` to `size`, redistributing
61    /// space among siblings using the same logic as a drag.
62    ///
63    /// Sizes are clamped to the panel's `size_range` and to the container.
64    /// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
65    /// persistence) see the change just as if the user had dragged a handle.
66    ///
67    /// Out-of-range indices are a no-op. For the last panel, space is taken
68    /// from the previous sibling (the last panel has no handle of its own).
69    pub fn resize_panel(
70        &mut self,
71        ix: usize,
72        size: Pixels,
73        window: &mut Window,
74        cx: &mut Context<Self>,
75    ) {
76        if ix >= self.sizes.len() {
77            return;
78        }
79        if ix + 1 < self.sizes.len() {
80            self.resize_panel_at_handle(ix, size, window, cx);
81        } else if ix > 0 {
82            // Last panel: drive its size by resizing the previous sibling so
83            // the freed space lands here.
84            let delta = self.sizes[ix] - size;
85            let prev = self.sizes[ix - 1];
86            self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
87        }
88        self.done_resizing(cx);
89    }
90
91    /// Insert a panel state at `ix`, or append it when no index is supplied.
92    ///
93    /// Existing panel sizes are redistributed so their total remains equal to
94    /// the current container size.
95    pub fn insert_panel(
96        &mut self,
97        size: Option<Pixels>,
98        ix: Option<usize>,
99        cx: &mut Context<Self>,
100    ) {
101        let panel_state = ResizablePanelState {
102            size,
103            ..Default::default()
104        };
105
106        let size = size.unwrap_or(PANEL_MIN_SIZE);
107
108        // We make sure that the size always sums up to the container size
109        // by reducing the size of all other panels first.
110        let container_size = self.container_size().max(px(1.));
111        let total_leftover_size = (container_size - size).max(px(1.));
112
113        for (i, panel) in self.panels.iter_mut().enumerate() {
114            let ratio = self.sizes[i] / container_size;
115            self.sizes[i] = total_leftover_size * ratio;
116            panel.size = Some(self.sizes[i]);
117        }
118
119        if let Some(ix) = ix {
120            self.panels.insert(ix, panel_state);
121            self.sizes.insert(ix, size);
122        } else {
123            self.panels.push(panel_state);
124            self.sizes.push(size);
125        };
126
127        cx.notify();
128    }
129
130    /// Adopt slot sizes decided by an owner that keeps its own record of the
131    /// layout — the dock's pane tree does.
132    ///
133    /// Unlike [`Self::insert_panel`], nothing is redistributed: the caller has
134    /// already decided how the space divides, and re-normalizing here would
135    /// undo exactly that decision. Slots the caller left unconstrained keep
136    /// whatever they had.
137    pub(crate) fn adopt_sizes(&mut self, sizes: &[Option<Pixels>], cx: &mut Context<Self>) {
138        let mut changed = false;
139        for (ix, size) in sizes.iter().enumerate() {
140            // The preference is mirrored exactly, `None` included. That is the
141            // load-bearing half: `insert_panel` resolves every existing
142            // panel's `None` into a concrete value as a side effect of
143            // redistributing, so after inserting one slot the caller's "these
144            // two are equally unconstrained" has quietly become "that one is
145            // pinned, this one is the only flexible slot" — and the flexible
146            // one then swallows whatever the pinned ones leave over.
147            if let Some(panel) = self.panels.get_mut(ix) {
148                if panel.size != *size {
149                    panel.size = *size;
150                    changed = true;
151                }
152            }
153
154            // The measurement only moves when the tree names a size; an
155            // unconstrained slot keeps whatever it was last laid out at until
156            // the next pass recomputes it.
157            let Some(size) = size else { continue };
158            if let Some(slot) = self.sizes.get_mut(ix) {
159                if *slot != *size {
160                    *slot = *size;
161                    changed = true;
162                }
163            }
164        }
165
166        if changed {
167            cx.notify();
168        }
169    }
170
171    pub(crate) fn sync_panels_count(
172        &mut self,
173        axis: Axis,
174        panels_count: usize,
175        cx: &mut Context<Self>,
176    ) {
177        let mut changed = self.axis != axis;
178        self.axis = axis;
179
180        if panels_count > self.panels.len() {
181            let diff = panels_count - self.panels.len();
182            self.panels
183                .extend(vec![ResizablePanelState::default(); diff]);
184            self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
185            changed = true;
186        }
187
188        if panels_count < self.panels.len() {
189            self.panels.truncate(panels_count);
190            self.sizes.truncate(panels_count);
191            changed = true;
192        }
193
194        if changed {
195            // We need to make sure the total size is in line with the container size.
196            self.adjust_to_container_size(cx);
197        }
198    }
199
200    /// Whether [`Self::update_panel_size`] with these arguments would change
201    /// anything. A panel reports its bounds as it prepaints, every frame it
202    /// is drawn; updating the state for the same bounds would mark it, and
203    /// every view that reads it, changed on every frame.
204    pub(crate) fn panel_size_changes(
205        &self,
206        panel_ix: usize,
207        bounds: Bounds<Pixels>,
208        size_range: &Range<Pixels>,
209    ) -> bool {
210        let Some(panel) = self.panels.get(panel_ix) else {
211            return true;
212        };
213        self.sizes.get(panel_ix).map(|size| size.as_f32()) == Some(PANEL_MIN_SIZE.as_f32())
214            || panel.bounds != bounds
215            || panel.size_range != *size_range
216    }
217
218    pub(crate) fn update_panel_size(
219        &mut self,
220        panel_ix: usize,
221        bounds: Bounds<Pixels>,
222        size_range: Range<Pixels>,
223        cx: &mut Context<Self>,
224    ) {
225        let size = bounds.size.along(self.axis);
226        // This check is only necessary to stop the very first panel from resizing on its own
227        // it needs to be passed when the panel is freshly created so we get the initial size,
228        // but its also fine when it sometimes passes later.
229        if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
230            self.sizes[panel_ix] = size;
231            self.panels[panel_ix].size = Some(size);
232        }
233        self.panels[panel_ix].bounds = bounds;
234        self.panels[panel_ix].size_range = size_range;
235        cx.notify();
236    }
237
238    /// Remove the panel at `panel_ix` and redistribute the remaining space.
239    pub fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
240        self.panels.remove(panel_ix);
241        self.sizes.remove(panel_ix);
242        if let Some(resizing_panel_ix) = self.resizing_panel_ix {
243            if resizing_panel_ix > panel_ix {
244                self.resizing_panel_ix = Some(resizing_panel_ix - 1);
245            }
246        }
247        self.adjust_to_container_size(cx);
248    }
249
250    /// Reset the panel at `panel_ix` while preserving its current size.
251    pub fn reset_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
252        let old_size = self.sizes[panel_ix];
253
254        self.panels[panel_ix] = ResizablePanelState::default();
255        self.sizes[panel_ix] = old_size;
256        self.adjust_to_container_size(cx);
257    }
258
259    /// Remove all panel state.
260    pub fn clear(&mut self) {
261        self.panels.clear();
262        self.sizes.clear();
263    }
264
265    #[inline]
266    /// Return the measured size of the group along its resize axis.
267    pub fn container_size(&self) -> Pixels {
268        self.bounds.size.along(self.axis)
269    }
270
271    pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
272        self.resizing_panel_ix = None;
273        cx.emit(ResizablePanelEvent::Resized);
274    }
275
276    fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
277        let Some(panel) = self.panels.get(ix) else {
278            return PANEL_MIN_SIZE..Pixels::MAX;
279        };
280
281        panel.size_range.clone()
282    }
283
284    fn sync_real_panel_sizes(&mut self, _: &App) {
285        for (i, panel) in self.panels.iter().enumerate() {
286            self.sizes[i] = panel.bounds.size.along(self.axis);
287        }
288    }
289
290    /// Resize the panel at `ix` by treating `ix` as the drag-handle position
291    /// (the handle that sits between panel `ix` and panel `ix + 1`). Returns
292    /// early on the last panel since there is no handle below it.
293    ///
294    /// This is the worker behind drag interactions and the public
295    /// [`Self::resize_panel`] API.
296    fn resize_panel_at_handle(
297        &mut self,
298        ix: usize,
299        size: Pixels,
300        _: &mut Window,
301        cx: &mut Context<Self>,
302    ) {
303        let old_sizes = self.sizes.clone();
304
305        let mut ix = ix;
306        // Only resize the left panels.
307        if ix >= old_sizes.len() - 1 {
308            return;
309        }
310        let container_size = self.container_size();
311        self.sync_real_panel_sizes(cx);
312
313        let move_changed = size - old_sizes[ix];
314        if move_changed == px(0.) {
315            return;
316        }
317
318        let size_range = self.panel_size_range(ix);
319        let new_size = size.clamp(size_range.start, size_range.end);
320        let is_expand = move_changed > px(0.);
321
322        let main_ix = ix;
323        let mut new_sizes = old_sizes.clone();
324
325        if is_expand {
326            let mut changed = new_size - old_sizes[ix];
327            new_sizes[ix] = new_size;
328
329            while changed > px(0.) && ix < old_sizes.len() - 1 {
330                ix += 1;
331                let size_range = self.panel_size_range(ix);
332                let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
333                let to_reduce = changed.min(available_size);
334                new_sizes[ix] -= to_reduce;
335                changed -= to_reduce;
336            }
337        } else {
338            let mut changed = new_size - size;
339            new_sizes[ix] = new_size;
340
341            while changed > px(0.) && ix > 0 {
342                ix -= 1;
343                let size_range = self.panel_size_range(ix);
344                let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
345                let to_reduce = changed.min(available_size);
346                changed -= to_reduce;
347                new_sizes[ix] -= to_reduce;
348            }
349
350            new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
351        }
352
353        // If total size exceeds container size, adjust the main panel
354        let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
355        if total_size > container_size {
356            let overflow = total_size - container_size;
357            new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
358        }
359
360        for (i, _) in old_sizes.iter().enumerate() {
361            let size = new_sizes[i];
362            self.panels[i].size = Some(size);
363        }
364        self.sizes = new_sizes;
365        cx.notify();
366    }
367
368    /// Adjust panel sizes according to the container size.
369    ///
370    /// When the container size changes, the panels should take up the same percentage as they did before.
371    fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
372        if self.container_size().is_zero() {
373            return;
374        }
375
376        // A panel with no size preference is laid out by flex, and its entry
377        // in `sizes` is a placeholder until something measures it. Rescaling
378        // by a ratio computed from that placeholder drags the panels that
379        // *do* have a preference along with it: a 200px sidebar beside one
380        // flexible panel comes back 587px wide on the frame after the first,
381        // which reads as the layout jumping once for no reason. Flex already
382        // fits the container, so there is nothing here to adjust.
383        if self.panels.iter().any(|panel| panel.size.is_none()) {
384            return;
385        }
386
387        let container_size = self.container_size();
388        let total = self.sizes.iter().map(|s| s.as_f32()).sum::<f32>();
389        if !total.is_finite() || total <= 0. {
390            return;
391        }
392        let total_size = px(total);
393
394        for i in 0..self.panels.len() {
395            let size = self.sizes[i];
396            let ratio = size / total_size;
397            let new_size = container_size * ratio;
398
399            self.sizes[i] = new_size;
400            self.panels[i].size = Some(new_size);
401        }
402        cx.notify();
403    }
404}
405
406impl EventEmitter<ResizablePanelEvent> for ResizableState {}
407
408#[derive(Debug, Clone, Default)]
409pub(crate) struct ResizablePanelState {
410    pub size: Option<Pixels>,
411    pub size_range: Range<Pixels>,
412    bounds: Bounds<Pixels>,
413}
414
415#[cfg(test)]
416mod tests {
417    use std::{
418        cell::{Cell, RefCell},
419        rc::Rc,
420    };
421
422    use gpui::{
423        App, AppContext as _, Context, InteractiveElement as _, IntoElement, Modifiers,
424        MouseButton, ParentElement as _, Pixels, Render, Styled as _, TestAppContext,
425        VisualTestContext, Window, div, point, prelude::FluentBuilder as _, px, size,
426    };
427
428    use super::{
429        ResizableState, ResizeHandleContext, ResizeHandleState, h_resizable, resizable_panel,
430    };
431
432    struct MixedSizingHarness {
433        width: Pixels,
434    }
435
436    impl Render for MixedSizingHarness {
437        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
438            div().w(self.width).h(px(100.)).child(
439                h_resizable("mixed-sizing")
440                    .child(
441                        resizable_panel()
442                            .size(px(240.))
443                            .child(div().size_full().debug_selector(|| "fixed-sidebar".into())),
444                    )
445                    .child(
446                        resizable_panel().child(
447                            div()
448                                .size_full()
449                                .debug_selector(|| "flexible-content".into()),
450                        ),
451                    ),
452            )
453        }
454    }
455
456    #[gpui::test]
457    fn mixed_sizing_is_stable_between_resize_and_followup_frame(cx: &mut TestAppContext) {
458        let (view, cx) = cx.add_window_view(|_, _| MixedSizingHarness { width: px(800.) });
459        cx.update(|window, cx| {
460            window.draw(cx).clear(cx);
461            window.draw(cx).clear(cx);
462        });
463        let before = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
464
465        view.update(cx, |view, cx| {
466            view.width = px(1200.);
467            cx.notify();
468        });
469        cx.run_until_parked();
470        let settled_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
471        cx.update(|window, cx| window.draw(cx).clear(cx));
472        let followup_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
473
474        // Resizable panels preserve their proportional sizing across a
475        // container resize; the important invariant is that applying the
476        // state on the follow-up frame does not move the divider again.
477        assert_ne!(settled_frame, before);
478        assert_eq!(followup_frame, settled_frame);
479    }
480
481    struct CallerStateHarness {
482        width: Pixels,
483        state: gpui::Entity<ResizableState>,
484    }
485
486    impl Render for CallerStateHarness {
487        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
488            div().w(self.width).h(px(100.)).child(
489                h_resizable("caller-state")
490                    .with_state(&self.state)
491                    .child(
492                        resizable_panel()
493                            .size(px(240.))
494                            .child(div().size_full().debug_selector(|| "cs-sidebar".into())),
495                    )
496                    .child(resizable_panel().child(div().size_full())),
497            )
498        }
499    }
500
501    /// A group whose state the caller owns (`with_state`, as the dock does)
502    /// has no `use_keyed_state` observer behind it, so the settling frame has
503    /// to be scheduled by the deferred notify rather than by that observer.
504    #[gpui::test]
505    fn caller_owned_state_settles_on_the_same_frame(cx: &mut TestAppContext) {
506        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
507        let (view, cx) = cx.add_window_view({
508            let state = state.clone();
509            move |_, _| CallerStateHarness {
510                width: px(800.),
511                state,
512            }
513        });
514        cx.update(|window, cx| {
515            window.draw(cx).clear(cx);
516            window.draw(cx).clear(cx);
517        });
518
519        view.update(cx, |view, cx| {
520            view.width = px(1200.);
521            cx.notify();
522        });
523        cx.run_until_parked();
524        let settled = cx.debug_bounds("cs-sidebar").unwrap().size.width;
525        cx.update(|window, cx| window.draw(cx).clear(cx));
526        let followup = cx.debug_bounds("cs-sidebar").unwrap().size.width;
527
528        assert_eq!(followup, settled, "settling frame must not be pending");
529    }
530
531    struct ResizableHarness {
532        state: gpui::Entity<ResizableState>,
533        resizes: Rc<Cell<usize>>,
534    }
535
536    impl Render for ResizableHarness {
537        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
538            div().w(px(400.)).h(px(100.)).child(
539                h_resizable("resizable")
540                    .with_state(&self.state)
541                    .on_resize({
542                        let resizes = self.resizes.clone();
543                        move |_, _, _| resizes.set(resizes.get() + 1)
544                    })
545                    .child(
546                        resizable_panel()
547                            .size(px(150.))
548                            .child(div().size_full().debug_selector(|| "first-panel".into())),
549                    )
550                    .child(
551                        resizable_panel()
552                            .size(px(250.))
553                            .child(div().size_full().debug_selector(|| "second-panel".into())),
554                    ),
555            )
556        }
557    }
558
559    fn harness(
560        cx: &mut TestAppContext,
561    ) -> (
562        &mut VisualTestContext,
563        gpui::Entity<ResizableState>,
564        Rc<Cell<usize>>,
565    ) {
566        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
567        let resizes = Rc::new(Cell::new(0));
568        let (_, cx) = cx.add_window_view({
569            let state = state.clone();
570            let resizes = resizes.clone();
571            move |_, _| ResizableHarness { state, resizes }
572        });
573        cx.update(|window, cx| window.draw(cx).clear(cx));
574        cx.update(|window, cx| window.draw(cx).clear(cx));
575        (cx, state, resizes)
576    }
577
578    /// A view reading a [`ResizableState`], counting its renders.
579    #[cfg(feature = "gpui-fast")]
580    struct StateReader {
581        state: gpui::Entity<ResizableState>,
582        renders: Rc<Cell<usize>>,
583    }
584
585    #[cfg(feature = "gpui-fast")]
586    impl Render for StateReader {
587        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
588            self.renders.set(self.renders.get() + 1);
589            let _ = self.state.read(cx).sizes.len();
590            div()
591        }
592    }
593
594    /// Panels beside a [`StateReader`] of their state.
595    #[cfg(feature = "gpui-fast")]
596    struct ReaderBesidePanels {
597        state: gpui::Entity<ResizableState>,
598        reader: gpui::Entity<StateReader>,
599    }
600
601    #[cfg(feature = "gpui-fast")]
602    impl Render for ReaderBesidePanels {
603        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
604            div()
605                .w(px(800.))
606                .h(px(100.))
607                .child(self.reader.clone())
608                .child(
609                    h_resizable("reader-panels")
610                        .with_state(&self.state)
611                        .child(resizable_panel().size(px(240.)).child(div().size_full()))
612                        .child(resizable_panel().child(div().size_full())),
613                )
614        }
615    }
616
617    /// Drawing panels again at the bounds they had changes nothing in their
618    /// state: a view reading it, beside them in a view drawn again, is not
619    /// built again for it.
620    #[cfg(feature = "gpui-fast")]
621    #[gpui::test]
622    fn redrawing_panels_where_they_were_leaves_their_readers_retained(cx: &mut TestAppContext) {
623        let renders = Rc::new(Cell::new(0));
624        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
625        let (view, cx) = cx.add_window_view({
626            let state = state.clone();
627            let renders = renders.clone();
628            move |_, cx| ReaderBesidePanels {
629                state: state.clone(),
630                reader: cx.new(|_| StateReader { state, renders }),
631            }
632        });
633        for _ in 0..3 {
634            cx.update(|window, cx| window.draw(cx).clear(cx));
635            cx.run_until_parked();
636        }
637        let before = renders.get();
638        for _ in 0..3 {
639            view.update(cx, |_, cx| cx.notify());
640            cx.update(|window, cx| window.draw(cx).clear(cx));
641            cx.run_until_parked();
642        }
643        assert_eq!(renders.get(), before, "the reader rendered again");
644    }
645
646    #[gpui::test]
647    fn dynamic_panel_lifecycle_is_owned_by_resizable_state(cx: &mut TestAppContext) {
648        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
649
650        cx.update(|cx| {
651            state.update(cx, |state, cx| {
652                state.bounds.size = size(px(400.), px(100.));
653                state.panels.push(Default::default());
654                state.sizes.push(px(400.));
655                state.insert_panel(Some(px(200.)), None, cx);
656                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
657
658                state.reset_panel(0, cx);
659                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
660
661                state.remove_panel(0, cx);
662                assert_eq!(state.sizes(), &vec![px(400.)]);
663
664                state.clear();
665                assert!(state.sizes().is_empty());
666            });
667        });
668    }
669
670    #[gpui::test]
671    fn group_measures_panels_and_programmatic_resize_uses_drag_rules(cx: &mut TestAppContext) {
672        let (cx, state, _) = harness(cx);
673        let first = cx.debug_bounds("first-panel").unwrap();
674        let second = cx.debug_bounds("second-panel").unwrap();
675        assert_eq!(first.size.width + second.size.width, px(400.));
676
677        cx.update(|window, cx| {
678            state.update(cx, |state, cx| {
679                state.resize_panel(0, px(220.), window, cx);
680            });
681            window.draw(cx).clear(cx);
682        });
683
684        state.read_with(cx, |state, _| {
685            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
686        });
687    }
688
689    #[gpui::test]
690    fn dragging_the_handle_resizes_and_emits_once(cx: &mut TestAppContext) {
691        let (cx, state, resizes) = harness(cx);
692        let boundary = cx.debug_bounds("second-panel").unwrap().left();
693
694        cx.simulate_mouse_down(
695            point(boundary - px(2.), px(50.)),
696            MouseButton::Left,
697            Modifiers::default(),
698        );
699        cx.simulate_mouse_move(
700            point(boundary + px(10.), px(50.)),
701            Some(MouseButton::Left),
702            Modifiers::default(),
703        );
704        cx.simulate_mouse_move(
705            point(px(220.), px(50.)),
706            Some(MouseButton::Left),
707            Modifiers::default(),
708        );
709        cx.simulate_mouse_up(
710            point(px(220.), px(50.)),
711            MouseButton::Left,
712            Modifiers::default(),
713        );
714
715        state.read_with(cx, |state, _| {
716            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
717        });
718        assert_eq!(resizes.get(), 1);
719    }
720
721    /// Reports every state its divider is rendered in, so a drag can be watched
722    /// from outside the handle.
723    ///
724    /// `covered` lays an occluding overlay over the whole group after it, the
725    /// way a sheet's backdrop or a toast would sit over a dock.
726    struct HandleStateHarness {
727        seen: Rc<RefCell<Vec<ResizeHandleState>>>,
728        covered: bool,
729    }
730
731    impl Render for HandleStateHarness {
732        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
733            let seen = self.seen.clone();
734            div()
735                .relative()
736                .w(px(400.))
737                .h(px(100.))
738                .child(
739                    h_resizable("handle-state")
740                        .with_handle_appearance(Rc::new(
741                            move |handle: &ResizeHandleContext, _: &mut Window, _: &mut App| {
742                                let mut seen = seen.borrow_mut();
743                                if seen.last() != Some(&handle.state()) {
744                                    seen.push(handle.state());
745                                }
746                                // Nothing painted: this renderer is here to watch.
747                                None
748                            },
749                        ))
750                        .child(resizable_panel().size(px(150.)).child(div().size_full()))
751                        .child(
752                            resizable_panel()
753                                .size(px(250.))
754                                .child(div().size_full().debug_selector(|| "hs-second".into())),
755                        ),
756                )
757                .when(self.covered, |this| {
758                    this.child(div().absolute().inset_0().occlude())
759                })
760        }
761    }
762
763    /// A handle under something else does not answer the pointer.
764    ///
765    /// The listeners used to test the pointer against the handle's bounds,
766    /// which read true through anything painted over it: a divider under a
767    /// sheet's backdrop lit up as the pointer crossed where it lay, and a
768    /// press there counted as a press on the handle. They ask the hitbox now,
769    /// and an occluding element in front of it answers for it.
770    #[gpui::test]
771    fn a_covered_handle_stays_idle(cx: &mut TestAppContext) {
772        let seen: Rc<RefCell<Vec<ResizeHandleState>>> = Rc::new(RefCell::new(Vec::new()));
773        let (_, cx) = cx.add_window_view({
774            let seen = seen.clone();
775            move |_, _| HandleStateHarness {
776                seen,
777                covered: true,
778            }
779        });
780        cx.update(|window, cx| window.draw(cx).clear(cx));
781        cx.update(|window, cx| window.draw(cx).clear(cx));
782
783        let boundary = cx.debug_bounds("hs-second").unwrap().left();
784        let on_handle = point(boundary - px(2.), px(50.));
785        let draw = |cx: &mut VisualTestContext| cx.update(|window, cx| window.draw(cx).clear(cx));
786
787        cx.simulate_mouse_move(on_handle, None, Modifiers::default());
788        draw(cx);
789        cx.simulate_mouse_down(on_handle, MouseButton::Left, Modifiers::default());
790        draw(cx);
791        cx.simulate_mouse_up(on_handle, MouseButton::Left, Modifiers::default());
792        draw(cx);
793
794        assert_eq!(*seen.borrow(), vec![ResizeHandleState::Idle]);
795    }
796
797    #[gpui::test]
798    fn a_handle_reports_the_press_and_the_drag_to_its_renderer(cx: &mut TestAppContext) {
799        let seen: Rc<RefCell<Vec<ResizeHandleState>>> = Rc::new(RefCell::new(Vec::new()));
800        let (_, cx) = cx.add_window_view({
801            let seen = seen.clone();
802            move |_, _| HandleStateHarness {
803                seen,
804                covered: false,
805            }
806        });
807        cx.update(|window, cx| window.draw(cx).clear(cx));
808        cx.update(|window, cx| window.draw(cx).clear(cx));
809
810        let boundary = cx.debug_bounds("hs-second").unwrap().left();
811        let on_handle = point(boundary - px(2.), px(50.));
812        let draw = |cx: &mut VisualTestContext| cx.update(|window, cx| window.draw(cx).clear(cx));
813
814        cx.simulate_mouse_move(on_handle, None, Modifiers::default());
815        draw(cx);
816        cx.simulate_mouse_down(on_handle, MouseButton::Left, Modifiers::default());
817        draw(cx);
818        cx.simulate_mouse_move(
819            point(px(260.), px(50.)),
820            Some(MouseButton::Left),
821            Modifiers::default(),
822        );
823        draw(cx);
824        cx.simulate_mouse_up(
825            point(px(390.), px(90.)),
826            MouseButton::Left,
827            Modifiers::default(),
828        );
829        draw(cx);
830
831        // Every one of these frames used to render `Idle`: the listeners wrote
832        // their progress into a copy of the handle's state, so no renderer ever
833        // saw a press, and `is_active` never once read true.
834        assert_eq!(
835            *seen.borrow(),
836            vec![
837                ResizeHandleState::Idle,
838                ResizeHandleState::Hovered,
839                ResizeHandleState::Pressed,
840                ResizeHandleState::Dragging,
841                ResizeHandleState::Idle,
842            ]
843        );
844    }
845
846    struct SizedGroupHarness;
847
848    impl Render for SizedGroupHarness {
849        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
850            div()
851                .w(px(400.))
852                .h(px(100.))
853                .child(
854                    h_resizable("sized-resizable").size(px(40.)).child(
855                        resizable_panel()
856                            .child(div().size_full().debug_selector(|| "sized-panel".into())),
857                    ),
858                )
859        }
860    }
861
862    #[gpui::test]
863    fn a_group_size_binds_the_cross_axis(cx: &mut TestAppContext) {
864        let (_, cx) = cx.add_window_view(|_, _| SizedGroupHarness);
865        cx.update(|window, cx| window.draw(cx).clear(cx));
866        cx.update(|window, cx| window.draw(cx).clear(cx));
867
868        let panel = cx.debug_bounds("sized-panel").unwrap();
869        assert_eq!(panel.size.width, px(400.));
870        assert_eq!(panel.size.height, px(40.));
871    }
872}