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