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::{cell::Cell, rc::Rc};
400
401    use gpui::{
402        AppContext as _, Context, InteractiveElement as _, IntoElement, Modifiers, MouseButton,
403        ParentElement as _, Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window,
404        div, point, px, size,
405    };
406
407    use super::{ResizableState, h_resizable, resizable_panel};
408
409    struct MixedSizingHarness {
410        width: Pixels,
411    }
412
413    impl Render for MixedSizingHarness {
414        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
415            div().w(self.width).h(px(100.)).child(
416                h_resizable("mixed-sizing")
417                    .child(
418                        resizable_panel()
419                            .size(px(240.))
420                            .child(div().size_full().debug_selector(|| "fixed-sidebar".into())),
421                    )
422                    .child(
423                        resizable_panel().child(
424                            div()
425                                .size_full()
426                                .debug_selector(|| "flexible-content".into()),
427                        ),
428                    ),
429            )
430        }
431    }
432
433    #[gpui::test]
434    fn mixed_sizing_is_stable_between_resize_and_followup_frame(cx: &mut TestAppContext) {
435        let (view, cx) = cx.add_window_view(|_, _| MixedSizingHarness { width: px(800.) });
436        cx.update(|window, cx| {
437            window.draw(cx).clear(cx);
438            window.draw(cx).clear(cx);
439        });
440        let before = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
441
442        view.update(cx, |view, cx| {
443            view.width = px(1200.);
444            cx.notify();
445        });
446        cx.run_until_parked();
447        let settled_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
448        cx.update(|window, cx| window.draw(cx).clear(cx));
449        let followup_frame = cx.debug_bounds("fixed-sidebar").unwrap().size.width;
450
451        // Resizable panels preserve their proportional sizing across a
452        // container resize; the important invariant is that applying the
453        // state on the follow-up frame does not move the divider again.
454        assert_ne!(settled_frame, before);
455        assert_eq!(followup_frame, settled_frame);
456    }
457
458    struct CallerStateHarness {
459        width: Pixels,
460        state: gpui::Entity<ResizableState>,
461    }
462
463    impl Render for CallerStateHarness {
464        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
465            div().w(self.width).h(px(100.)).child(
466                h_resizable("caller-state")
467                    .with_state(&self.state)
468                    .child(
469                        resizable_panel()
470                            .size(px(240.))
471                            .child(div().size_full().debug_selector(|| "cs-sidebar".into())),
472                    )
473                    .child(resizable_panel().child(div().size_full())),
474            )
475        }
476    }
477
478    /// A group whose state the caller owns (`with_state`, as the dock does)
479    /// has no `use_keyed_state` observer behind it, so the settling frame has
480    /// to be scheduled by the deferred notify rather than by that observer.
481    #[gpui::test]
482    fn caller_owned_state_settles_on_the_same_frame(cx: &mut TestAppContext) {
483        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
484        let (view, cx) = cx.add_window_view({
485            let state = state.clone();
486            move |_, _| CallerStateHarness {
487                width: px(800.),
488                state,
489            }
490        });
491        cx.update(|window, cx| {
492            window.draw(cx).clear(cx);
493            window.draw(cx).clear(cx);
494        });
495
496        view.update(cx, |view, cx| {
497            view.width = px(1200.);
498            cx.notify();
499        });
500        cx.run_until_parked();
501        let settled = cx.debug_bounds("cs-sidebar").unwrap().size.width;
502        cx.update(|window, cx| window.draw(cx).clear(cx));
503        let followup = cx.debug_bounds("cs-sidebar").unwrap().size.width;
504
505        assert_eq!(followup, settled, "settling frame must not be pending");
506    }
507
508    struct ResizableHarness {
509        state: gpui::Entity<ResizableState>,
510        resizes: Rc<Cell<usize>>,
511    }
512
513    impl Render for ResizableHarness {
514        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
515            div().w(px(400.)).h(px(100.)).child(
516                h_resizable("resizable")
517                    .with_state(&self.state)
518                    .on_resize({
519                        let resizes = self.resizes.clone();
520                        move |_, _, _| resizes.set(resizes.get() + 1)
521                    })
522                    .child(
523                        resizable_panel()
524                            .size(px(150.))
525                            .child(div().size_full().debug_selector(|| "first-panel".into())),
526                    )
527                    .child(
528                        resizable_panel()
529                            .size(px(250.))
530                            .child(div().size_full().debug_selector(|| "second-panel".into())),
531                    ),
532            )
533        }
534    }
535
536    fn harness(
537        cx: &mut TestAppContext,
538    ) -> (
539        &mut VisualTestContext,
540        gpui::Entity<ResizableState>,
541        Rc<Cell<usize>>,
542    ) {
543        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
544        let resizes = Rc::new(Cell::new(0));
545        let (_, cx) = cx.add_window_view({
546            let state = state.clone();
547            let resizes = resizes.clone();
548            move |_, _| ResizableHarness { state, resizes }
549        });
550        cx.update(|window, cx| window.draw(cx).clear(cx));
551        cx.update(|window, cx| window.draw(cx).clear(cx));
552        (cx, state, resizes)
553    }
554
555    #[gpui::test]
556    fn dynamic_panel_lifecycle_is_owned_by_resizable_state(cx: &mut TestAppContext) {
557        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
558
559        cx.update(|cx| {
560            state.update(cx, |state, cx| {
561                state.bounds.size = size(px(400.), px(100.));
562                state.panels.push(Default::default());
563                state.sizes.push(px(400.));
564                state.insert_panel(Some(px(200.)), None, cx);
565                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
566
567                state.reset_panel(0, cx);
568                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
569
570                state.remove_panel(0, cx);
571                assert_eq!(state.sizes(), &vec![px(400.)]);
572
573                state.clear();
574                assert!(state.sizes().is_empty());
575            });
576        });
577    }
578
579    #[gpui::test]
580    fn group_measures_panels_and_programmatic_resize_uses_drag_rules(cx: &mut TestAppContext) {
581        let (cx, state, _) = harness(cx);
582        let first = cx.debug_bounds("first-panel").unwrap();
583        let second = cx.debug_bounds("second-panel").unwrap();
584        assert_eq!(first.size.width + second.size.width, px(400.));
585
586        cx.update(|window, cx| {
587            state.update(cx, |state, cx| {
588                state.resize_panel(0, px(220.), window, cx);
589            });
590            window.draw(cx).clear(cx);
591        });
592
593        state.read_with(cx, |state, _| {
594            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
595        });
596    }
597
598    #[gpui::test]
599    fn dragging_the_handle_resizes_and_emits_once(cx: &mut TestAppContext) {
600        let (cx, state, resizes) = harness(cx);
601        let boundary = cx.debug_bounds("second-panel").unwrap().left();
602
603        cx.simulate_mouse_down(
604            point(boundary - px(2.), px(50.)),
605            MouseButton::Left,
606            Modifiers::default(),
607        );
608        cx.simulate_mouse_move(
609            point(boundary + px(10.), px(50.)),
610            Some(MouseButton::Left),
611            Modifiers::default(),
612        );
613        cx.simulate_mouse_move(
614            point(px(220.), px(50.)),
615            Some(MouseButton::Left),
616            Modifiers::default(),
617        );
618        cx.simulate_mouse_up(
619            point(px(220.), px(50.)),
620            MouseButton::Left,
621            Modifiers::default(),
622        );
623
624        state.read_with(cx, |state, _| {
625            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
626        });
627        assert_eq!(resizes.get(), 1);
628    }
629
630    struct SizedGroupHarness;
631
632    impl Render for SizedGroupHarness {
633        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
634            div()
635                .w(px(400.))
636                .h(px(100.))
637                .child(
638                    h_resizable("sized-resizable").size(px(40.)).child(
639                        resizable_panel()
640                            .child(div().size_full().debug_selector(|| "sized-panel".into())),
641                    ),
642                )
643        }
644    }
645
646    #[gpui::test]
647    fn a_group_size_binds_the_cross_axis(cx: &mut TestAppContext) {
648        let (_, cx) = cx.add_window_view(|_, _| SizedGroupHarness);
649        cx.update(|window, cx| window.draw(cx).clear(cx));
650        cx.update(|window, cx| window.draw(cx).clear(cx));
651
652        let panel = cx.debug_bounds("sized-panel").unwrap();
653        assert_eq!(panel.size.width, px(400.));
654        assert_eq!(panel.size.height, px(40.));
655    }
656}