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