Skip to main content

ui/components/
resizable.rs

1//! Horizontal split panels with a draggable divider and min/max clamping.
2//!
3//! Divider drag reuses the pointer-delta + clamp pattern from column resize
4//! (`redistributable_columns.rs` / `data_table.rs`).
5
6use std::rc::Rc;
7
8use gpui::{AnyElement, Bounds, Context, DragMoveEvent, Empty, Render, canvas};
9use smallvec::SmallVec;
10
11use crate::prelude::*;
12
13/// Drag payload for [`ResizableHandle`].
14#[derive(Debug)]
15struct ResizableDrag {
16    start_fraction: f32,
17    start_x: Pixels,
18}
19
20fn clamp_fraction(fraction: f32, min: f32, max: f32) -> f32 {
21    fraction.clamp(min, max)
22}
23
24/// A panel inside a [`ResizablePanelGroup`].
25#[derive(IntoElement)]
26pub struct ResizablePanel {
27    children: SmallVec<[AnyElement; 2]>,
28    fraction: f32,
29}
30
31impl ResizablePanel {
32    pub fn new() -> Self {
33        Self {
34            children: SmallVec::new(),
35            fraction: 1.0,
36        }
37    }
38
39    pub(crate) fn fraction(mut self, fraction: f32) -> Self {
40        self.fraction = fraction;
41        self
42    }
43}
44
45impl Default for ResizablePanel {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl ParentElement for ResizablePanel {
52    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
53        self.children.extend(elements);
54    }
55}
56
57impl RenderOnce for ResizablePanel {
58    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
59        div()
60            .h_full()
61            .overflow_hidden()
62            .when(self.fraction >= 1.0, |this| this.flex_grow())
63            .when(self.fraction < 1.0, |this| {
64                this.flex_shrink_0().w(relative(self.fraction))
65            })
66            .bg(semantic::surface(cx))
67            .children(self.children)
68    }
69}
70
71/// Draggable divider between two [`ResizablePanel`]s.
72#[derive(IntoElement)]
73pub struct ResizableHandle;
74
75impl RenderOnce for ResizableHandle {
76    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
77        div()
78            .id("resizable-handle")
79            .w(px(1.))
80            .h_full()
81            .bg(semantic::border(cx))
82    }
83}
84
85/// Horizontal panel group with a draggable split handle.
86///
87/// Create with `cx.new(|_| ResizablePanelGroup::new(left, right))` where
88/// `left`/`right` are render callbacks returning panel content each frame.
89pub struct ResizablePanelGroup {
90    left: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
91    right: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
92    left_fraction: f32,
93    min_left_fraction: f32,
94    max_left_fraction: f32,
95    container_bounds: Rc<std::cell::Cell<Option<Bounds<Pixels>>>>,
96}
97
98impl ResizablePanelGroup {
99    pub fn new(
100        left: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
101        right: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
102    ) -> Self {
103        Self {
104            left: Rc::new(left),
105            right: Rc::new(right),
106            left_fraction: 0.5,
107            min_left_fraction: 0.2,
108            max_left_fraction: 0.8,
109            container_bounds: Rc::new(std::cell::Cell::new(None)),
110        }
111    }
112
113    pub fn left_fraction(mut self, fraction: f32) -> Self {
114        self.left_fraction =
115            clamp_fraction(fraction, self.min_left_fraction, self.max_left_fraction);
116        self
117    }
118
119    pub fn min_left_fraction(mut self, min: f32) -> Self {
120        self.min_left_fraction = min.clamp(0.05, 0.95);
121        self
122    }
123
124    pub fn max_left_fraction(mut self, max: f32) -> Self {
125        self.max_left_fraction = max.clamp(self.min_left_fraction, 0.95);
126        self
127    }
128
129    pub fn left_fraction_value(&self) -> f32 {
130        self.left_fraction
131    }
132
133    fn on_drag_move(&mut self, event: &DragMoveEvent<ResizableDrag>, cx: &mut Context<Self>) {
134        let drag = event.drag(cx);
135        let container_width = self
136            .container_bounds
137            .get()
138            .map(|b| b.size.width)
139            .unwrap_or(px(1.));
140        if container_width <= px(0.) {
141            return;
142        }
143        let delta = event.event.position.x - drag.start_x;
144        let delta_fraction = delta / container_width;
145        self.left_fraction = clamp_fraction(
146            drag.start_fraction + delta_fraction,
147            self.min_left_fraction,
148            self.max_left_fraction,
149        );
150        cx.notify();
151    }
152}
153
154impl Render for ResizablePanelGroup {
155    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
156        let left_fraction = self.left_fraction;
157        let right_fraction = 1.0 - left_fraction;
158        let left = (self.left)(window, cx);
159        let right = (self.right)(window, cx);
160        let container_bounds = self.container_bounds.clone();
161        let mouse_x = window.mouse_position().x;
162
163        let handle = div()
164            .id("resizable-handle-hit")
165            .w(px(8.))
166            .h_full()
167            .flex_shrink_0()
168            .cursor_col_resize()
169            .flex()
170            .items_center()
171            .justify_center()
172            .child(ResizableHandle)
173            .on_drag(
174                ResizableDrag {
175                    start_fraction: left_fraction,
176                    start_x: mouse_x,
177                },
178                |_, _, _, cx| cx.new(|_| Empty),
179            )
180            .on_drag_move::<ResizableDrag>(cx.listener(|this, event, _, cx| {
181                this.on_drag_move(event, cx);
182            }));
183
184        h_flex()
185            .id("resizable-panel-group")
186            .w_full()
187            .h(px(240.))
188            .rounded_md()
189            .border_1()
190            .border_color(semantic::border(cx))
191            .overflow_hidden()
192            .child({
193                let measure = container_bounds.clone();
194                canvas(
195                    move |bounds, _, _| measure.set(Some(bounds)),
196                    |_, _, _, _| {},
197                )
198                .absolute()
199                .size_full()
200            })
201            .child(
202                ResizablePanel::new()
203                    .fraction(left_fraction)
204                    .child(div().h_full().p_4().child(left)),
205            )
206            .child(handle)
207            .child(
208                ResizablePanel::new()
209                    .fraction(right_fraction)
210                    .child(div().h_full().p_4().child(right)),
211            )
212    }
213}
214
215/// Gallery catalog entry for [`ResizablePanelGroup`].
216#[derive(IntoElement, RegisterComponent)]
217pub struct ResizablePreview;
218
219impl RenderOnce for ResizablePreview {
220    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
221        cx.new(|_| {
222            ResizablePanelGroup::new(
223                |_, _| {
224                    v_flex()
225                        .gap_2()
226                        .child(Label::new("Left panel").weight(gpui::FontWeight::SEMIBOLD))
227                        .child(Label::new("Drag the handle to resize.").color(Color::Muted))
228                        .into_any_element()
229                },
230                |_, _| {
231                    v_flex()
232                        .gap_2()
233                        .child(Label::new("Right panel").weight(gpui::FontWeight::SEMIBOLD))
234                        .child(Label::new("Clamped between 20% and 80%.").color(Color::Muted))
235                        .into_any_element()
236                },
237            )
238            .min_left_fraction(0.2)
239            .max_left_fraction(0.8)
240        })
241    }
242}
243
244impl Component for ResizablePreview {
245    fn scope() -> ComponentScope {
246        ComponentScope::Layout
247    }
248
249    fn description() -> Option<&'static str> {
250        Some("Horizontal split panels with a draggable divider and min/max clamping.")
251    }
252
253    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
254        ResizablePreview
255            .render(window, cx)
256            .into_any_element()
257            .into()
258    }
259}