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    pub(crate) fn update_panel_size(
201        &mut self,
202        panel_ix: usize,
203        bounds: Bounds<Pixels>,
204        size_range: Range<Pixels>,
205        cx: &mut Context<Self>,
206    ) {
207        let size = bounds.size.along(self.axis);
208        // This check is only necessary to stop the very first panel from resizing on its own
209        // it needs to be passed when the panel is freshly created so we get the initial size,
210        // but its also fine when it sometimes passes later.
211        if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
212            self.sizes[panel_ix] = size;
213            self.panels[panel_ix].size = Some(size);
214        }
215        self.panels[panel_ix].bounds = bounds;
216        self.panels[panel_ix].size_range = size_range;
217        cx.notify();
218    }
219
220    /// Remove the panel at `panel_ix` and redistribute the remaining space.
221    pub fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
222        self.panels.remove(panel_ix);
223        self.sizes.remove(panel_ix);
224        if let Some(resizing_panel_ix) = self.resizing_panel_ix {
225            if resizing_panel_ix > panel_ix {
226                self.resizing_panel_ix = Some(resizing_panel_ix - 1);
227            }
228        }
229        self.adjust_to_container_size(cx);
230    }
231
232    /// Reset the panel at `panel_ix` while preserving its current size.
233    pub fn reset_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
234        let old_size = self.sizes[panel_ix];
235
236        self.panels[panel_ix] = ResizablePanelState::default();
237        self.sizes[panel_ix] = old_size;
238        self.adjust_to_container_size(cx);
239    }
240
241    /// Remove all panel state.
242    pub fn clear(&mut self) {
243        self.panels.clear();
244        self.sizes.clear();
245    }
246
247    #[inline]
248    /// Return the measured size of the group along its resize axis.
249    pub fn container_size(&self) -> Pixels {
250        self.bounds.size.along(self.axis)
251    }
252
253    pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
254        self.resizing_panel_ix = None;
255        cx.emit(ResizablePanelEvent::Resized);
256    }
257
258    fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
259        let Some(panel) = self.panels.get(ix) else {
260            return PANEL_MIN_SIZE..Pixels::MAX;
261        };
262
263        panel.size_range.clone()
264    }
265
266    fn sync_real_panel_sizes(&mut self, _: &App) {
267        for (i, panel) in self.panels.iter().enumerate() {
268            self.sizes[i] = panel.bounds.size.along(self.axis);
269        }
270    }
271
272    /// Resize the panel at `ix` by treating `ix` as the drag-handle position
273    /// (the handle that sits between panel `ix` and panel `ix + 1`). Returns
274    /// early on the last panel since there is no handle below it.
275    ///
276    /// This is the worker behind drag interactions and the public
277    /// [`Self::resize_panel`] API.
278    fn resize_panel_at_handle(
279        &mut self,
280        ix: usize,
281        size: Pixels,
282        _: &mut Window,
283        cx: &mut Context<Self>,
284    ) {
285        let old_sizes = self.sizes.clone();
286
287        let mut ix = ix;
288        // Only resize the left panels.
289        if ix >= old_sizes.len() - 1 {
290            return;
291        }
292        let container_size = self.container_size();
293        self.sync_real_panel_sizes(cx);
294
295        let move_changed = size - old_sizes[ix];
296        if move_changed == px(0.) {
297            return;
298        }
299
300        let size_range = self.panel_size_range(ix);
301        let new_size = size.clamp(size_range.start, size_range.end);
302        let is_expand = move_changed > px(0.);
303
304        let main_ix = ix;
305        let mut new_sizes = old_sizes.clone();
306
307        if is_expand {
308            let mut changed = new_size - old_sizes[ix];
309            new_sizes[ix] = new_size;
310
311            while changed > px(0.) && ix < old_sizes.len() - 1 {
312                ix += 1;
313                let size_range = self.panel_size_range(ix);
314                let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
315                let to_reduce = changed.min(available_size);
316                new_sizes[ix] -= to_reduce;
317                changed -= to_reduce;
318            }
319        } else {
320            let mut changed = new_size - size;
321            new_sizes[ix] = new_size;
322
323            while changed > px(0.) && ix > 0 {
324                ix -= 1;
325                let size_range = self.panel_size_range(ix);
326                let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
327                let to_reduce = changed.min(available_size);
328                changed -= to_reduce;
329                new_sizes[ix] -= to_reduce;
330            }
331
332            new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
333        }
334
335        // If total size exceeds container size, adjust the main panel
336        let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
337        if total_size > container_size {
338            let overflow = total_size - container_size;
339            new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
340        }
341
342        for (i, _) in old_sizes.iter().enumerate() {
343            let size = new_sizes[i];
344            self.panels[i].size = Some(size);
345        }
346        self.sizes = new_sizes;
347        cx.notify();
348    }
349
350    /// Adjust panel sizes according to the container size.
351    ///
352    /// When the container size changes, the panels should take up the same percentage as they did before.
353    fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
354        if self.container_size().is_zero() {
355            return;
356        }
357
358        // A panel with no size preference is laid out by flex, and its entry
359        // in `sizes` is a placeholder until something measures it. Rescaling
360        // by a ratio computed from that placeholder drags the panels that
361        // *do* have a preference along with it: a 200px sidebar beside one
362        // flexible panel comes back 587px wide on the frame after the first,
363        // which reads as the layout jumping once for no reason. Flex already
364        // fits the container, so there is nothing here to adjust.
365        if self.panels.iter().any(|panel| panel.size.is_none()) {
366            return;
367        }
368
369        let container_size = self.container_size();
370        let total = self.sizes.iter().map(|s| s.as_f32()).sum::<f32>();
371        if !total.is_finite() || total <= 0. {
372            return;
373        }
374        let total_size = px(total);
375
376        for i in 0..self.panels.len() {
377            let size = self.sizes[i];
378            let ratio = size / total_size;
379            let new_size = container_size * ratio;
380
381            self.sizes[i] = new_size;
382            self.panels[i].size = Some(new_size);
383        }
384        cx.notify();
385    }
386}
387
388impl EventEmitter<ResizablePanelEvent> for ResizableState {}
389
390#[derive(Debug, Clone, Default)]
391pub(crate) struct ResizablePanelState {
392    pub size: Option<Pixels>,
393    pub size_range: Range<Pixels>,
394    bounds: Bounds<Pixels>,
395}
396
397#[cfg(test)]
398mod tests {
399    use std::{
400        cell::{Cell, RefCell},
401        rc::Rc,
402    };
403
404    use gpui::{
405        App, AppContext as _, Context, InteractiveElement as _, IntoElement, Modifiers,
406        MouseButton, ParentElement as _, Pixels, Render, Styled as _, TestAppContext,
407        VisualTestContext, Window, div, point, prelude::FluentBuilder as _, px, size,
408    };
409
410    use super::{
411        ResizableState, ResizeHandleContext, ResizeHandleState, h_resizable, resizable_panel,
412    };
413
414    struct MixedSizingHarness {
415        width: Pixels,
416    }
417
418    impl Render for MixedSizingHarness {
419        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
420            div().w(self.width).h(px(100.)).child(
421                h_resizable("mixed-sizing")
422                    .child(
423                        resizable_panel()
424                            .size(px(240.))
425                            .child(div().size_full().debug_selector(|| "fixed-sidebar".into())),
426                    )
427                    .child(
428                        resizable_panel().child(
429                            div()
430                                .size_full()
431                                .debug_selector(|| "flexible-content".into()),
432                        ),
433                    ),
434            )
435        }
436    }
437
438    #[gpui::test]
439    fn mixed_sizing_is_stable_between_resize_and_followup_frame(cx: &mut TestAppContext) {
440        let (view, cx) = cx.add_window_view(|_, _| MixedSizingHarness { width: px(800.) });
441        cx.update(|window, cx| {
442            window.draw(cx).clear(cx);
443            window.draw(cx).clear(cx);
444        });
445        let before = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
446
447        view.update(cx, |view, cx| {
448            view.width = px(1200.);
449            cx.notify();
450        });
451        cx.run_until_parked();
452        let settled_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
453        cx.update(|window, cx| window.draw(cx).clear(cx));
454        let followup_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
455
456        // Resizable panels preserve their proportional sizing across a
457        // container resize; the important invariant is that applying the
458        // state on the follow-up frame does not move the divider again.
459        assert_ne!(settled_frame, before);
460        assert_eq!(followup_frame, settled_frame);
461    }
462
463    struct CallerStateHarness {
464        width: Pixels,
465        state: gpui::Entity<ResizableState>,
466    }
467
468    impl Render for CallerStateHarness {
469        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
470            div().w(self.width).h(px(100.)).child(
471                h_resizable("caller-state")
472                    .with_state(&self.state)
473                    .child(
474                        resizable_panel()
475                            .size(px(240.))
476                            .child(div().size_full().debug_selector(|| "cs-sidebar".into())),
477                    )
478                    .child(resizable_panel().child(div().size_full())),
479            )
480        }
481    }
482
483    /// A group whose state the caller owns (`with_state`, as the dock does)
484    /// has no `use_keyed_state` observer behind it, so the settling frame has
485    /// to be scheduled by the deferred notify rather than by that observer.
486    #[gpui::test]
487    fn caller_owned_state_settles_on_the_same_frame(cx: &mut TestAppContext) {
488        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
489        let (view, cx) = cx.add_window_view({
490            let state = state.clone();
491            move |_, _| CallerStateHarness {
492                width: px(800.),
493                state,
494            }
495        });
496        cx.update(|window, cx| {
497            window.draw(cx).clear(cx);
498            window.draw(cx).clear(cx);
499        });
500
501        view.update(cx, |view, cx| {
502            view.width = px(1200.);
503            cx.notify();
504        });
505        cx.run_until_parked();
506        let settled = cx.debug_bounds("cs-sidebar").unwrap().size.width;
507        cx.update(|window, cx| window.draw(cx).clear(cx));
508        let followup = cx.debug_bounds("cs-sidebar").unwrap().size.width;
509
510        assert_eq!(followup, settled, "settling frame must not be pending");
511    }
512
513    struct ResizableHarness {
514        state: gpui::Entity<ResizableState>,
515        resizes: Rc<Cell<usize>>,
516    }
517
518    impl Render for ResizableHarness {
519        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
520            div().w(px(400.)).h(px(100.)).child(
521                h_resizable("resizable")
522                    .with_state(&self.state)
523                    .on_resize({
524                        let resizes = self.resizes.clone();
525                        move |_, _, _| resizes.set(resizes.get() + 1)
526                    })
527                    .child(
528                        resizable_panel()
529                            .size(px(150.))
530                            .child(div().size_full().debug_selector(|| "first-panel".into())),
531                    )
532                    .child(
533                        resizable_panel()
534                            .size(px(250.))
535                            .child(div().size_full().debug_selector(|| "second-panel".into())),
536                    ),
537            )
538        }
539    }
540
541    fn harness(
542        cx: &mut TestAppContext,
543    ) -> (
544        &mut VisualTestContext,
545        gpui::Entity<ResizableState>,
546        Rc<Cell<usize>>,
547    ) {
548        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
549        let resizes = Rc::new(Cell::new(0));
550        let (_, cx) = cx.add_window_view({
551            let state = state.clone();
552            let resizes = resizes.clone();
553            move |_, _| ResizableHarness { state, resizes }
554        });
555        cx.update(|window, cx| window.draw(cx).clear(cx));
556        cx.update(|window, cx| window.draw(cx).clear(cx));
557        (cx, state, resizes)
558    }
559
560    #[gpui::test]
561    fn dynamic_panel_lifecycle_is_owned_by_resizable_state(cx: &mut TestAppContext) {
562        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
563
564        cx.update(|cx| {
565            state.update(cx, |state, cx| {
566                state.bounds.size = size(px(400.), px(100.));
567                state.panels.push(Default::default());
568                state.sizes.push(px(400.));
569                state.insert_panel(Some(px(200.)), None, cx);
570                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
571
572                state.reset_panel(0, cx);
573                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
574
575                state.remove_panel(0, cx);
576                assert_eq!(state.sizes(), &vec![px(400.)]);
577
578                state.clear();
579                assert!(state.sizes().is_empty());
580            });
581        });
582    }
583
584    #[gpui::test]
585    fn group_measures_panels_and_programmatic_resize_uses_drag_rules(cx: &mut TestAppContext) {
586        let (cx, state, _) = harness(cx);
587        let first = cx.debug_bounds("first-panel").unwrap();
588        let second = cx.debug_bounds("second-panel").unwrap();
589        assert_eq!(first.size.width + second.size.width, px(400.));
590
591        cx.update(|window, cx| {
592            state.update(cx, |state, cx| {
593                state.resize_panel(0, px(220.), window, cx);
594            });
595            window.draw(cx).clear(cx);
596        });
597
598        state.read_with(cx, |state, _| {
599            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
600        });
601    }
602
603    #[gpui::test]
604    fn dragging_the_handle_resizes_and_emits_once(cx: &mut TestAppContext) {
605        let (cx, state, resizes) = harness(cx);
606        let boundary = cx.debug_bounds("second-panel").unwrap().left();
607
608        cx.simulate_mouse_down(
609            point(boundary - px(2.), px(50.)),
610            MouseButton::Left,
611            Modifiers::default(),
612        );
613        cx.simulate_mouse_move(
614            point(boundary + px(10.), px(50.)),
615            Some(MouseButton::Left),
616            Modifiers::default(),
617        );
618        cx.simulate_mouse_move(
619            point(px(220.), px(50.)),
620            Some(MouseButton::Left),
621            Modifiers::default(),
622        );
623        cx.simulate_mouse_up(
624            point(px(220.), px(50.)),
625            MouseButton::Left,
626            Modifiers::default(),
627        );
628
629        state.read_with(cx, |state, _| {
630            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
631        });
632        assert_eq!(resizes.get(), 1);
633    }
634
635    /// Reports every state its divider is rendered in, so a drag can be watched
636    /// from outside the handle.
637    ///
638    /// `covered` lays an occluding overlay over the whole group after it, the
639    /// way a sheet's backdrop or a toast would sit over a dock.
640    struct HandleStateHarness {
641        seen: Rc<RefCell<Vec<ResizeHandleState>>>,
642        covered: bool,
643    }
644
645    impl Render for HandleStateHarness {
646        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
647            let seen = self.seen.clone();
648            div()
649                .relative()
650                .w(px(400.))
651                .h(px(100.))
652                .child(
653                    h_resizable("handle-state")
654                        .with_handle_appearance(Rc::new(
655                            move |handle: &ResizeHandleContext, _: &mut Window, _: &mut App| {
656                                let mut seen = seen.borrow_mut();
657                                if seen.last() != Some(&handle.state()) {
658                                    seen.push(handle.state());
659                                }
660                                // Nothing painted: this renderer is here to watch.
661                                None
662                            },
663                        ))
664                        .child(resizable_panel().size(px(150.)).child(div().size_full()))
665                        .child(
666                            resizable_panel()
667                                .size(px(250.))
668                                .child(div().size_full().debug_selector(|| "hs-second".into())),
669                        ),
670                )
671                .when(self.covered, |this| {
672                    this.child(div().absolute().inset_0().occlude())
673                })
674        }
675    }
676
677    /// A handle under something else does not answer the pointer.
678    ///
679    /// The listeners used to test the pointer against the handle's bounds,
680    /// which read true through anything painted over it: a divider under a
681    /// sheet's backdrop lit up as the pointer crossed where it lay, and a
682    /// press there counted as a press on the handle. They ask the hitbox now,
683    /// and an occluding element in front of it answers for it.
684    #[gpui::test]
685    fn a_covered_handle_stays_idle(cx: &mut TestAppContext) {
686        let seen: Rc<RefCell<Vec<ResizeHandleState>>> = Rc::new(RefCell::new(Vec::new()));
687        let (_, cx) = cx.add_window_view({
688            let seen = seen.clone();
689            move |_, _| HandleStateHarness {
690                seen,
691                covered: true,
692            }
693        });
694        cx.update(|window, cx| window.draw(cx).clear(cx));
695        cx.update(|window, cx| window.draw(cx).clear(cx));
696
697        let boundary = cx.debug_bounds("hs-second").unwrap().left();
698        let on_handle = point(boundary - px(2.), px(50.));
699        let draw = |cx: &mut VisualTestContext| cx.update(|window, cx| window.draw(cx).clear(cx));
700
701        cx.simulate_mouse_move(on_handle, None, Modifiers::default());
702        draw(cx);
703        cx.simulate_mouse_down(on_handle, MouseButton::Left, Modifiers::default());
704        draw(cx);
705        cx.simulate_mouse_up(on_handle, MouseButton::Left, Modifiers::default());
706        draw(cx);
707
708        assert_eq!(*seen.borrow(), vec![ResizeHandleState::Idle]);
709    }
710
711    #[gpui::test]
712    fn a_handle_reports_the_press_and_the_drag_to_its_renderer(cx: &mut TestAppContext) {
713        let seen: Rc<RefCell<Vec<ResizeHandleState>>> = Rc::new(RefCell::new(Vec::new()));
714        let (_, cx) = cx.add_window_view({
715            let seen = seen.clone();
716            move |_, _| HandleStateHarness {
717                seen,
718                covered: false,
719            }
720        });
721        cx.update(|window, cx| window.draw(cx).clear(cx));
722        cx.update(|window, cx| window.draw(cx).clear(cx));
723
724        let boundary = cx.debug_bounds("hs-second").unwrap().left();
725        let on_handle = point(boundary - px(2.), px(50.));
726        let draw = |cx: &mut VisualTestContext| cx.update(|window, cx| window.draw(cx).clear(cx));
727
728        cx.simulate_mouse_move(on_handle, None, Modifiers::default());
729        draw(cx);
730        cx.simulate_mouse_down(on_handle, MouseButton::Left, Modifiers::default());
731        draw(cx);
732        cx.simulate_mouse_move(
733            point(px(260.), px(50.)),
734            Some(MouseButton::Left),
735            Modifiers::default(),
736        );
737        draw(cx);
738        cx.simulate_mouse_up(
739            point(px(390.), px(90.)),
740            MouseButton::Left,
741            Modifiers::default(),
742        );
743        draw(cx);
744
745        // Every one of these frames used to render `Idle`: the listeners wrote
746        // their progress into a copy of the handle's state, so no renderer ever
747        // saw a press, and `is_active` never once read true.
748        assert_eq!(
749            *seen.borrow(),
750            vec![
751                ResizeHandleState::Idle,
752                ResizeHandleState::Hovered,
753                ResizeHandleState::Pressed,
754                ResizeHandleState::Dragging,
755                ResizeHandleState::Idle,
756            ]
757        );
758    }
759
760    struct SizedGroupHarness;
761
762    impl Render for SizedGroupHarness {
763        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
764            div()
765                .w(px(400.))
766                .h(px(100.))
767                .child(
768                    h_resizable("sized-resizable").size(px(40.)).child(
769                        resizable_panel()
770                            .child(div().size_full().debug_selector(|| "sized-panel".into())),
771                    ),
772                )
773        }
774    }
775
776    #[gpui::test]
777    fn a_group_size_binds_the_cross_axis(cx: &mut TestAppContext) {
778        let (_, cx) = cx.add_window_view(|_, _| SizedGroupHarness);
779        cx.update(|window, cx| window.draw(cx).clear(cx));
780        cx.update(|window, cx| window.draw(cx).clear(cx));
781
782        let panel = cx.debug_bounds("sized-panel").unwrap();
783        assert_eq!(panel.size.width, px(400.));
784        assert_eq!(panel.size.height, px(40.));
785    }
786}