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 _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div,
404        point, px, size,
405    };
406
407    use super::{ResizableState, h_resizable, resizable_panel};
408
409    struct ResizableHarness {
410        state: gpui::Entity<ResizableState>,
411        resizes: Rc<Cell<usize>>,
412    }
413
414    impl Render for ResizableHarness {
415        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
416            div().w(px(400.)).h(px(100.)).child(
417                h_resizable("resizable")
418                    .with_state(&self.state)
419                    .on_resize({
420                        let resizes = self.resizes.clone();
421                        move |_, _, _| resizes.set(resizes.get() + 1)
422                    })
423                    .child(
424                        resizable_panel()
425                            .size(px(150.))
426                            .child(div().size_full().debug_selector(|| "first-panel".into())),
427                    )
428                    .child(
429                        resizable_panel()
430                            .size(px(250.))
431                            .child(div().size_full().debug_selector(|| "second-panel".into())),
432                    ),
433            )
434        }
435    }
436
437    fn harness(
438        cx: &mut TestAppContext,
439    ) -> (
440        &mut VisualTestContext,
441        gpui::Entity<ResizableState>,
442        Rc<Cell<usize>>,
443    ) {
444        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
445        let resizes = Rc::new(Cell::new(0));
446        let (_, cx) = cx.add_window_view({
447            let state = state.clone();
448            let resizes = resizes.clone();
449            move |_, _| ResizableHarness { state, resizes }
450        });
451        cx.update(|window, cx| window.draw(cx).clear(cx));
452        cx.update(|window, cx| window.draw(cx).clear(cx));
453        (cx, state, resizes)
454    }
455
456    #[gpui::test]
457    fn dynamic_panel_lifecycle_is_owned_by_resizable_state(cx: &mut TestAppContext) {
458        let state = cx.update(|cx| cx.new(|_| ResizableState::default()));
459
460        cx.update(|cx| {
461            state.update(cx, |state, cx| {
462                state.bounds.size = size(px(400.), px(100.));
463                state.panels.push(Default::default());
464                state.sizes.push(px(400.));
465                state.insert_panel(Some(px(200.)), None, cx);
466                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
467
468                state.reset_panel(0, cx);
469                assert_eq!(state.sizes(), &vec![px(200.), px(200.)]);
470
471                state.remove_panel(0, cx);
472                assert_eq!(state.sizes(), &vec![px(400.)]);
473
474                state.clear();
475                assert!(state.sizes().is_empty());
476            });
477        });
478    }
479
480    #[gpui::test]
481    fn group_measures_panels_and_programmatic_resize_uses_drag_rules(cx: &mut TestAppContext) {
482        let (cx, state, _) = harness(cx);
483        let first = cx.debug_bounds("first-panel").unwrap();
484        let second = cx.debug_bounds("second-panel").unwrap();
485        assert_eq!(first.size.width + second.size.width, px(400.));
486
487        cx.update(|window, cx| {
488            state.update(cx, |state, cx| {
489                state.resize_panel(0, px(220.), window, cx);
490            });
491            window.draw(cx).clear(cx);
492        });
493
494        state.read_with(cx, |state, _| {
495            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
496        });
497    }
498
499    #[gpui::test]
500    fn dragging_the_handle_resizes_and_emits_once(cx: &mut TestAppContext) {
501        let (cx, state, resizes) = harness(cx);
502        let boundary = cx.debug_bounds("second-panel").unwrap().left();
503
504        cx.simulate_mouse_down(
505            point(boundary - px(2.), px(50.)),
506            MouseButton::Left,
507            Modifiers::default(),
508        );
509        cx.simulate_mouse_move(
510            point(boundary + px(10.), px(50.)),
511            Some(MouseButton::Left),
512            Modifiers::default(),
513        );
514        cx.simulate_mouse_move(
515            point(px(220.), px(50.)),
516            Some(MouseButton::Left),
517            Modifiers::default(),
518        );
519        cx.simulate_mouse_up(
520            point(px(220.), px(50.)),
521            MouseButton::Left,
522            Modifiers::default(),
523        );
524
525        state.read_with(cx, |state, _| {
526            assert_eq!(state.sizes(), &vec![px(220.), px(180.)]);
527        });
528        assert_eq!(resizes.get(), 1);
529    }
530
531    struct SizedGroupHarness;
532
533    impl Render for SizedGroupHarness {
534        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
535            div()
536                .w(px(400.))
537                .h(px(100.))
538                .child(
539                    h_resizable("sized-resizable").size(px(40.)).child(
540                        resizable_panel()
541                            .child(div().size_full().debug_selector(|| "sized-panel".into())),
542                    ),
543                )
544        }
545    }
546
547    #[gpui::test]
548    fn a_group_size_binds_the_cross_axis(cx: &mut TestAppContext) {
549        let (_, cx) = cx.add_window_view(|_, _| SizedGroupHarness);
550        cx.update(|window, cx| window.draw(cx).clear(cx));
551        cx.update(|window, cx| window.draw(cx).clear(cx));
552
553        let panel = cx.debug_bounds("sized-panel").unwrap();
554        assert_eq!(panel.size.width, px(400.));
555        assert_eq!(panel.size.height, px(40.));
556    }
557}