Skip to main content

guise/
splitpanel.rs

1//! `SplitPanel` — two live panes with a draggable divider (gpui entity).
2//!
3//! Pane content is a builder closure re-invoked every render (like `Tabs`), so
4//! panes show live data — including another `SplitPanel`'s element, which is
5//! how nested layouts are built.
6//!
7//! ```ignore
8//! let split = cx.new(|cx| {
9//!     SplitPanel::new(cx)
10//!         .direction(SplitDirection::Horizontal)
11//!         .ratio(0.3)
12//!         .min_first(120.0)
13//!         .first(|_, _| Text::new("Sidebar"))
14//!         .second(|_, _| Text::new("Main content"))
15//! });
16//! cx.subscribe(&split, |_, _, SplitPanelEvent::Resized(ratio), _| { /* … */ })
17//!     .detach();
18//! ```
19
20use gpui::prelude::*;
21use gpui::{
22    div, px, App, Context, DragMoveEvent, Empty, EntityId, EventEmitter, IntoElement, Window,
23};
24
25use crate::data::Content;
26use crate::devtools::Probed;
27use crate::style::FlexExt;
28use crate::theme::theme;
29
30/// Which way the panes are laid out. `Horizontal` places them side by side
31/// (a vertical divider, column-resize cursor); `Vertical` stacks them.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum SplitDirection {
34    #[default]
35    Horizontal,
36    Vertical,
37}
38
39/// Emitted while the divider is dragged. Carries the new first-pane ratio
40/// in `0.0..=1.0`.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum SplitPanelEvent {
43    Resized(f32),
44}
45
46/// Drag payload for the divider. Carries the owning panel's id so nested
47/// `SplitPanel`s ignore each other's drags (`on_drag_move` fires for every
48/// active drag of this type, anywhere in the window).
49struct DividerDrag {
50    panel: EntityId,
51}
52
53/// A resizable two-pane layout. Create with
54/// `cx.new(|cx| SplitPanel::new(cx).first(..).second(..))` and give the
55/// element a sized parent — the panel fills it.
56pub struct SplitPanel {
57    direction: SplitDirection,
58    first: Option<Content>,
59    second: Option<Content>,
60    ratio: f32,
61    min_first: f32,
62    min_second: f32,
63    handle_size: f32,
64}
65
66impl EventEmitter<SplitPanelEvent> for SplitPanel {}
67
68impl SplitPanel {
69    pub fn new(_cx: &mut Context<Self>) -> Self {
70        SplitPanel {
71            direction: SplitDirection::Horizontal,
72            first: None,
73            second: None,
74            ratio: 0.5,
75            min_first: 40.0,
76            min_second: 40.0,
77            handle_size: 6.0,
78        }
79    }
80
81    pub fn direction(mut self, direction: SplitDirection) -> Self {
82        self.direction = direction;
83        self
84    }
85
86    /// The first pane (left / top). Rebuilt each render so it can show live
87    /// data — including another `SplitPanel`'s element for nesting.
88    pub fn first<E>(mut self, content: impl Fn(&mut Window, &mut App) -> E + 'static) -> Self
89    where
90        E: IntoElement,
91    {
92        self.first = Some(Box::new(move |window, cx| {
93            content(window, cx).into_any_element()
94        }));
95        self
96    }
97
98    /// The second pane (right / bottom). Rebuilt each render.
99    pub fn second<E>(mut self, content: impl Fn(&mut Window, &mut App) -> E + 'static) -> Self
100    where
101        E: IntoElement,
102    {
103        self.second = Some(Box::new(move |window, cx| {
104            content(window, cx).into_any_element()
105        }));
106        self
107    }
108
109    /// Initial share of the axis given to the first pane (clamped to `0..=1`).
110    pub fn ratio(mut self, ratio: f32) -> Self {
111        self.ratio = ratio.clamp(0.0, 1.0);
112        self
113    }
114
115    /// Minimum pixel size of the first pane while dragging.
116    pub fn min_first(mut self, min: f32) -> Self {
117        self.min_first = min.max(0.0);
118        self
119    }
120
121    /// Minimum pixel size of the second pane while dragging.
122    pub fn min_second(mut self, min: f32) -> Self {
123        self.min_second = min.max(0.0);
124        self
125    }
126
127    /// Thickness of the divider's grab area in pixels.
128    pub fn handle_size(mut self, size: f32) -> Self {
129        self.handle_size = size.max(1.0);
130        self
131    }
132
133    /// The current first-pane ratio.
134    pub fn current_ratio(&self) -> f32 {
135        self.ratio
136    }
137}
138
139/// Resolve a divider drag into the next first-pane ratio. `pos` is the pointer
140/// offset from the container's leading edge along the split axis, `extent` the
141/// container's size on that axis. The divider centers under the pointer, and
142/// both panes keep their minimum sizes.
143fn drag_ratio(pos: f32, extent: f32, handle: f32, min_first: f32, min_second: f32) -> f32 {
144    let avail = (extent - handle).max(1.0);
145    let lo = min_first.min(avail);
146    let hi = (avail - min_second).max(lo);
147    (pos - handle * 0.5).clamp(lo, hi) / avail
148}
149
150impl Render for SplitPanel {
151    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
152        let t = theme(cx);
153        let line = t.border().hsla();
154        let grip = t.primary().alpha(0.35);
155
156        let horizontal = matches!(self.direction, SplitDirection::Horizontal);
157        let handle = self.handle_size;
158        let ratio = self.ratio.clamp(0.0, 1.0);
159        let min_first = self.min_first;
160        let min_second = self.min_second;
161        let panel = cx.entity().entity_id();
162
163        let first = self.first.as_ref().map(|build| build(window, cx));
164        let second = self.second.as_ref().map(|build| build(window, cx));
165
166        let mut first_pane = div().flex_basis(px(0.0)).grow(ratio).overflow_hidden();
167        first_pane = if horizontal {
168            first_pane.min_w(px(min_first))
169        } else {
170            first_pane.min_h(px(min_first))
171        };
172        if let Some(el) = first {
173            first_pane = first_pane.child(el);
174        }
175
176        let mut second_pane = div()
177            .flex_basis(px(0.0))
178            .grow(1.0 - ratio)
179            .overflow_hidden();
180        second_pane = if horizontal {
181            second_pane.min_w(px(min_second))
182        } else {
183            second_pane.min_h(px(min_second))
184        };
185        if let Some(el) = second {
186            second_pane = second_pane.child(el);
187        }
188
189        let mut divider = div()
190            .id("guise-splitpanel-divider")
191            .flex_none()
192            .flex()
193            .items_center()
194            .justify_center()
195            .hover(move |s| s.bg(grip))
196            .on_drag(DividerDrag { panel }, |_, _offset, _window, cx| {
197                cx.new(|_| Empty)
198            });
199        divider = if horizontal {
200            divider
201                .w(px(handle))
202                .h_full()
203                .cursor_col_resize()
204                .child(div().w(px(1.0)).h_full().bg(line))
205        } else {
206            divider
207                .h(px(handle))
208                .w_full()
209                .cursor_row_resize()
210                .child(div().h(px(1.0)).w_full().bg(line))
211        };
212
213        let mut root = div()
214            .id("guise-splitpanel")
215            .size_full()
216            .flex()
217            .on_drag_move(cx.listener(
218                move |this, ev: &DragMoveEvent<DividerDrag>, _window, cx| {
219                    let source = ev.drag(cx).panel;
220                    if source != panel {
221                        return;
222                    }
223                    let bounds = ev.bounds;
224                    let (pos, extent) = if matches!(this.direction, SplitDirection::Horizontal) {
225                        (
226                            f32::from(ev.event.position.x - bounds.left()),
227                            f32::from(bounds.size.width),
228                        )
229                    } else {
230                        (
231                            f32::from(ev.event.position.y - bounds.top()),
232                            f32::from(bounds.size.height),
233                        )
234                    };
235                    let next = drag_ratio(
236                        pos,
237                        extent,
238                        this.handle_size,
239                        this.min_first,
240                        this.min_second,
241                    );
242                    if (next - this.ratio).abs() > f32::EPSILON {
243                        this.ratio = next;
244                        cx.emit(SplitPanelEvent::Resized(next));
245                        cx.notify();
246                    }
247                },
248            ));
249        root = if horizontal {
250            root.flex_row()
251        } else {
252            root.flex_col()
253        };
254
255        root.child(first_pane)
256            .child(divider)
257            .child(second_pane)
258            .probe("SplitPanel")
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::drag_ratio;
265
266    #[test]
267    fn centered_pointer_is_half() {
268        // 206px container, 6px handle: pointer at 103 puts 100px of the
269        // 200px of pane space on each side.
270        assert_eq!(drag_ratio(103.0, 206.0, 6.0, 0.0, 0.0), 0.5);
271    }
272
273    #[test]
274    fn clamps_to_min_first() {
275        let ratio = drag_ratio(10.0, 406.0, 6.0, 80.0, 0.0);
276        assert_eq!(ratio, 80.0 / 400.0);
277    }
278
279    #[test]
280    fn clamps_to_min_second() {
281        let ratio = drag_ratio(400.0, 406.0, 6.0, 0.0, 120.0);
282        assert_eq!(ratio, (400.0 - 120.0) / 400.0);
283    }
284
285    #[test]
286    fn overshoot_stays_in_range() {
287        assert_eq!(drag_ratio(-500.0, 206.0, 6.0, 0.0, 0.0), 0.0);
288        assert_eq!(drag_ratio(900.0, 206.0, 6.0, 0.0, 0.0), 1.0);
289    }
290
291    #[test]
292    fn degenerate_extent_prefers_min_first() {
293        // Container smaller than the minimums: the first pane's floor wins,
294        // and the result never divides by zero.
295        let ratio = drag_ratio(30.0, 60.0, 6.0, 100.0, 100.0);
296        assert_eq!(ratio, 1.0);
297    }
298}