1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum SplitDirection {
33 #[default]
34 Horizontal,
35 Vertical,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum SplitPanelEvent {
42 Resized(f32),
43}
44
45struct DividerDrag {
49 panel: EntityId,
50}
51
52pub 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 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 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 pub fn ratio(mut self, ratio: f32) -> Self {
110 self.ratio = ratio.clamp(0.0, 1.0);
111 self
112 }
113
114 pub fn min_first(mut self, min: f32) -> Self {
116 self.min_first = min.max(0.0);
117 self
118 }
119
120 pub fn min_second(mut self, min: f32) -> Self {
122 self.min_second = min.max(0.0);
123 self
124 }
125
126 pub fn handle_size(mut self, size: f32) -> Self {
128 self.handle_size = size.max(1.0);
129 self
130 }
131
132 pub fn current_ratio(&self) -> f32 {
134 self.ratio
135 }
136}
137
138fn 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 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 let ratio = drag_ratio(30.0, 60.0, 6.0, 100.0, 100.0);
292 assert_eq!(ratio, 1.0);
293 }
294}