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