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(
218        cx.listener(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
256      .child(first_pane)
257      .child(divider)
258      .child(second_pane)
259      .probe("SplitPanel")
260  }
261}
262
263#[cfg(test)]
264mod tests {
265  use super::drag_ratio;
266
267  #[test]
268  fn centered_pointer_is_half() {
269    // 206px container, 6px handle: pointer at 103 puts 100px of the
270    // 200px of pane space on each side.
271    assert_eq!(drag_ratio(103.0, 206.0, 6.0, 0.0, 0.0), 0.5);
272  }
273
274  #[test]
275  fn clamps_to_min_first() {
276    let ratio = drag_ratio(10.0, 406.0, 6.0, 80.0, 0.0);
277    assert_eq!(ratio, 80.0 / 400.0);
278  }
279
280  #[test]
281  fn clamps_to_min_second() {
282    let ratio = drag_ratio(400.0, 406.0, 6.0, 0.0, 120.0);
283    assert_eq!(ratio, (400.0 - 120.0) / 400.0);
284  }
285
286  #[test]
287  fn overshoot_stays_in_range() {
288    assert_eq!(drag_ratio(-500.0, 206.0, 6.0, 0.0, 0.0), 0.0);
289    assert_eq!(drag_ratio(900.0, 206.0, 6.0, 0.0, 0.0), 1.0);
290  }
291
292  #[test]
293  fn degenerate_extent_prefers_min_first() {
294    // Container smaller than the minimums: the first pane's floor wins,
295    // and the result never divides by zero.
296    let ratio = drag_ratio(30.0, 60.0, 6.0, 100.0, 100.0);
297    assert_eq!(ratio, 1.0);
298  }
299}