1use std::cell::{Cell, RefCell};
9use std::collections::HashMap;
10use std::rc::Rc;
11
12use gpui::{
13 AccessibleAction, AnyElement, App, ClickEvent, Global, InteractiveElement, IntoElement,
14 MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
15 Window, div, prelude::FluentBuilder, px, relative,
16};
17use gpui_kit_semantics::{NodeSpec, Role, Semantic};
18use gpui_kit_theme::ActiveTheme;
19
20use crate::foundation::{Disableable, FocusRing, Ident};
21use crate::layout::measure;
22use crate::strings::{ActiveStrings, StringKey};
23
24pub(crate) const HANDLE: f32 = 7.0;
27const GRIP: f32 = 24.0;
28
29const DEFAULT_STEP: f32 = 24.0;
31
32type ResizeHandler = Rc<dyn Fn(f32, &mut Window, &mut App)>;
33type CollapseHandler = Rc<dyn Fn(SplitSide, &mut Window, &mut App)>;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SplitAxis {
38 #[default]
40 Horizontal,
41 Vertical,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SplitSide {
48 Start,
50 End,
52}
53
54impl SplitSide {
55 pub fn name(self) -> &'static str {
56 match self {
57 Self::Start => "start",
58 Self::End => "end",
59 }
60 }
61}
62
63#[derive(IntoElement)]
65pub struct SplitPane {
66 ident: Ident,
67 axis: SplitAxis,
68 ratio: f32,
69 min_start: f32,
70 min_end: f32,
71 step: f32,
72 collapsible: bool,
73 handle_label: Option<SharedString>,
74 start: Option<AnyElement>,
75 end: Option<AnyElement>,
76 disabled: bool,
77 on_resize: Option<ResizeHandler>,
78 on_collapse: Option<CollapseHandler>,
79}
80
81impl std::fmt::Debug for SplitPane {
82 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 formatter
84 .debug_struct("SplitPane")
85 .field("ident", &self.ident)
86 .field("axis", &self.axis)
87 .field("ratio", &self.ratio)
88 .field("min", &(self.min_start, self.min_end))
89 .field("collapsible", &self.collapsible)
90 .field("disabled", &self.disabled)
91 .field("has_handler", &self.on_resize.is_some())
92 .finish()
93 }
94}
95
96impl SplitPane {
97 pub fn new(ident: impl Into<Ident>) -> Self {
98 Self {
99 ident: ident.into(),
100 axis: SplitAxis::Horizontal,
101 ratio: 0.5,
102 min_start: 0.0,
103 min_end: 0.0,
104 step: DEFAULT_STEP,
105 collapsible: false,
106 handle_label: None,
107 start: None,
108 end: None,
109 disabled: false,
110 on_resize: None,
111 on_collapse: None,
112 }
113 }
114
115 pub fn axis(mut self, axis: SplitAxis) -> Self {
116 self.axis = axis;
117 self
118 }
119
120 pub fn horizontal(self) -> Self {
121 self.axis(SplitAxis::Horizontal)
122 }
123
124 pub fn vertical(self) -> Self {
125 self.axis(SplitAxis::Vertical)
126 }
127
128 pub fn ratio(mut self, ratio: f32) -> Self {
130 self.ratio = ratio.clamp(0.0, 1.0);
131 self
132 }
133
134 pub fn min_sizes(mut self, start: f32, end: f32) -> Self {
136 self.min_start = start.max(0.0);
137 self.min_end = end.max(0.0);
138 self
139 }
140
141 pub fn step(mut self, step: f32) -> Self {
143 if step > 0.0 {
144 self.step = step;
145 }
146 self
147 }
148
149 pub fn collapsible(mut self, collapsible: bool) -> Self {
151 self.collapsible = collapsible;
152 self
153 }
154
155 pub fn handle_label(mut self, label: impl Into<SharedString>) -> Self {
157 self.handle_label = Some(label.into());
158 self
159 }
160
161 pub fn start(mut self, pane: impl IntoElement) -> Self {
162 self.start = Some(pane.into_any_element());
163 self
164 }
165
166 pub fn end(mut self, pane: impl IntoElement) -> Self {
167 self.end = Some(pane.into_any_element());
168 self
169 }
170
171 pub fn on_resize(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
172 self.on_resize = Some(Rc::new(handler));
173 self
174 }
175
176 pub fn on_collapse(
178 mut self,
179 handler: impl Fn(SplitSide, &mut Window, &mut App) + 'static,
180 ) -> Self {
181 self.on_collapse = Some(Rc::new(handler));
182 self
183 }
184}
185
186impl Disableable for SplitPane {
187 fn disabled(mut self, disabled: bool) -> Self {
190 self.disabled = disabled;
191 self
192 }
193}
194
195pub(crate) fn limits(extent: f32, min_start: f32, min_end: f32) -> (f32, f32) {
201 if extent <= 0.0 {
202 return (0.0, 1.0);
203 }
204 let low = (min_start / extent).clamp(0.0, 1.0);
205 let high = (1.0 - min_end / extent).clamp(0.0, 1.0);
206 if low > high {
207 let middle = (low + high) / 2.0;
208 return (middle, middle);
209 }
210 (low, high)
211}
212
213pub(crate) fn ratio_at(position: f32, origin: f32, extent: f32, low: f32, high: f32) -> f32 {
216 if extent <= 0.0 {
217 return low;
218 }
219 ((position - origin) / extent).clamp(low, high)
220}
221
222pub(crate) fn collapse_target(ratio: f32) -> SplitSide {
227 if ratio <= 0.5 {
228 SplitSide::Start
229 } else {
230 SplitSide::End
231 }
232}
233
234impl RenderOnce for SplitPane {
235 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
236 let theme = cx.theme().clone();
237 let horizontal = self.axis == SplitAxis::Horizontal;
238 let ratio = self.ratio.clamp(0.0, 1.0);
239 let actionable = !self.disabled && self.on_resize.is_some();
240 let (min_start, min_end, step_px) = (self.min_start, self.min_end, self.step);
241
242 let measured = measure::cell(&self.ident.semantic_id(), cx);
245 let frame = measured.get();
246 let extent = if horizontal {
247 f32::from(frame.size.width)
248 } else {
249 f32::from(frame.size.height)
250 };
251 let (low, high) = limits(extent, min_start, min_end);
252 let divider_ident = self.ident.child("divider");
253
254 let mut divider = div()
255 .id(divider_ident.element_id())
256 .flex()
257 .flex_none()
258 .items_center()
259 .justify_center()
260 .when(horizontal, |element| element.w(px(HANDLE)).h_full())
261 .when(!horizontal, |element| element.h(px(HANDLE)).w_full())
262 .bg(theme.colors.panel)
263 .child(
264 div()
265 .rounded_full()
266 .bg(theme.colors.hairline_strong)
267 .when(horizontal, |grip| {
268 grip.w(px(theme.borders.hairline)).h(px(GRIP))
269 })
270 .when(!horizontal, |grip| {
271 grip.h(px(theme.borders.hairline)).w(px(GRIP))
272 }),
273 )
274 .when(actionable, |element| {
275 element
276 .cursor_pointer()
277 .tab_index(0)
278 .hover(|style| style.bg(theme.colors.hover))
279 .focus_ring(&theme)
280 });
281
282 let dragging = drag_flag(&self.ident, cx);
286 if actionable {
287 let started = Rc::clone(&dragging);
288 divider = divider.on_mouse_down(MouseButton::Left, move |_, _, _| started.set(true));
289 }
290
291 if let (true, Some(handler)) = (actionable, self.on_resize.clone()) {
292 let bounds = Rc::clone(&measured);
293 divider = divider.on_key_down(move |event, window, cx| {
294 let frame = bounds.get();
295 let extent = if horizontal {
296 f32::from(frame.size.width)
297 } else {
298 f32::from(frame.size.height)
299 };
300 let (low, high) = limits(extent, min_start, min_end);
301 let step = if extent > 0.0 { step_px / extent } else { 0.0 };
302 let backward = if horizontal { "left" } else { "up" };
303 let forward = if horizontal { "right" } else { "down" };
304 let next = match event.keystroke.key.as_str() {
305 key if key == backward => ratio - step,
306 key if key == forward => ratio + step,
307 "home" => low,
308 "end" => high,
309 _ => return,
310 }
311 .clamp(low, high);
312 cx.stop_propagation();
313 if (next - ratio).abs() < f32::EPSILON {
314 return;
315 }
316 handler(next, window, cx);
317 });
318 }
319
320 if let (true, Some(handler)) = (actionable, self.on_resize.clone()) {
321 let bounds = Rc::clone(&measured);
322 divider = divider.on_a11y_action(AccessibleAction::Increment, move |_, window, cx| {
323 let frame = bounds.get();
324 let extent = if horizontal {
325 f32::from(frame.size.width)
326 } else {
327 f32::from(frame.size.height)
328 };
329 let (low, high) = limits(extent, min_start, min_end);
330 let step = if extent > 0.0 { step_px / extent } else { 0.0 };
331 let next = (ratio + step).clamp(low, high);
332 if (next - ratio).abs() >= f32::EPSILON {
333 handler(next, window, cx);
334 }
335 });
336 }
337 if let (true, Some(handler)) = (actionable, self.on_resize.clone()) {
338 let bounds = Rc::clone(&measured);
339 divider = divider.on_a11y_action(AccessibleAction::Decrement, move |_, window, cx| {
340 let frame = bounds.get();
341 let extent = if horizontal {
342 f32::from(frame.size.width)
343 } else {
344 f32::from(frame.size.height)
345 };
346 let (low, high) = limits(extent, min_start, min_end);
347 let step = if extent > 0.0 { step_px / extent } else { 0.0 };
348 let next = (ratio - step).clamp(low, high);
349 if (next - ratio).abs() >= f32::EPSILON {
350 handler(next, window, cx);
351 }
352 });
353 }
354
355 if let (true, true, Some(handler)) =
356 (actionable, self.collapsible, self.on_collapse.clone())
357 {
358 divider = divider.on_click(move |event: &ClickEvent, window, cx| {
359 if event.click_count() < 2 {
360 return;
361 }
362 handler(collapse_target(ratio), window, cx);
363 });
364 }
365
366 let divider = divider.semantic_in(
367 cx,
368 NodeSpec::new(divider_ident.semantic_id(), Role::Splitter)
369 .parent(self.ident.semantic_id())
370 .disabled(!actionable)
371 .text(
372 self.handle_label
373 .clone()
374 .unwrap_or_else(|| cx.strings().text(StringKey::SplitResizeHandle)),
375 )
376 .orientation(if horizontal {
377 gpui::accesskit::Orientation::Vertical
378 } else {
379 gpui::accesskit::Orientation::Horizontal
380 })
381 .range(low, high, ratio.clamp(low, high)),
382 );
383
384 let start = (ratio > 0.0).then_some(self.start).flatten().map(|pane| {
387 pane_frame(&self.ident, "start", cx)
388 .when(horizontal, |element| element.w(relative(ratio)).h_full())
389 .when(!horizontal, |element| element.h(relative(ratio)).w_full())
390 .child(pane)
391 });
392 let end = (ratio < 1.0)
393 .then_some(self.end)
394 .flatten()
395 .map(|pane| pane_frame(&self.ident, "end", cx).flex_1().child(pane));
396
397 let mut frame = div()
398 .on_children_prepainted({
399 let measured = Rc::clone(&measured);
400 move |bounds, window, _| {
401 if let Some(first) = bounds.first() {
402 measure::record(&measured, *first, window);
403 }
404 }
405 })
406 .id(self.ident.element_id())
407 .size_full()
408 .overflow_hidden();
409
410 if let (true, Some(handler)) = (actionable, self.on_resize.clone()) {
411 let bounds = Rc::clone(&measured);
412 let held = Rc::clone(&dragging);
413 frame = frame.on_mouse_move(move |event, window, cx| {
414 if !held.get() {
415 return;
416 }
417 if event.pressed_button != Some(MouseButton::Left) {
418 held.set(false);
419 return;
420 }
421 let frame = bounds.get();
422 let (origin, extent) = if horizontal {
423 (f32::from(frame.left()), f32::from(frame.size.width))
424 } else {
425 (f32::from(frame.top()), f32::from(frame.size.height))
426 };
427 let position = if horizontal {
428 f32::from(event.position.x)
429 } else {
430 f32::from(event.position.y)
431 };
432 let (low, high) = limits(extent, min_start, min_end);
433 let next = ratio_at(position, origin, extent, low, high);
434 if (next - ratio).abs() < f32::EPSILON {
438 return;
439 }
440 handler(next, window, cx);
441 });
442 let released = Rc::clone(&dragging);
443 frame = frame.on_mouse_up(MouseButton::Left, move |_, _, _| released.set(false));
444 }
445
446 frame.child(
447 div()
448 .flex()
449 .when(horizontal, |element| element.flex_row())
450 .when(!horizontal, |element| element.flex_col())
451 .size_full()
452 .items_stretch()
453 .overflow_hidden()
454 .children(start)
455 .child(divider)
456 .children(end)
457 .semantic_in(
458 cx,
459 NodeSpec::new(self.ident.semantic_id(), Role::Group)
460 .value(format!("{ratio:.3}")),
461 ),
462 )
463 }
464}
465
466#[derive(Default)]
467struct Dragging(RefCell<HashMap<SharedString, Rc<Cell<bool>>>>);
468
469impl Global for Dragging {}
470
471fn drag_flag(ident: &Ident, cx: &mut App) -> Rc<Cell<bool>> {
477 if !cx.has_global::<Dragging>() {
478 cx.set_global(Dragging::default());
479 }
480 let mut flags = cx.global::<Dragging>().0.borrow_mut();
481 Rc::clone(flags.entry(ident.semantic_id()).or_default())
482}
483
484fn pane_frame(ident: &Ident, side: &str, cx: &App) -> gpui::Stateful<gpui::Div> {
485 let pane = ident.child(side);
486 div()
487 .flex()
488 .flex_col()
489 .flex_none()
490 .min_w(px(0.0))
491 .min_h(px(0.0))
492 .overflow_hidden()
493 .semantic_in(
494 cx,
495 NodeSpec::new(pane.semantic_id(), Role::Group).parent(ident.semantic_id()),
496 )
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn a_pane_cannot_be_dragged_past_its_minimum() {
505 let (low, high) = limits(400.0, 100.0, 80.0);
506 assert_eq!(low, 0.25);
507 assert_eq!(high, 0.8);
508 assert_eq!(ratio_at(0.0, 0.0, 400.0, low, high), 0.25);
509 assert_eq!(ratio_at(400.0, 0.0, 400.0, low, high), 0.8);
510 assert_eq!(ratio_at(200.0, 0.0, 400.0, low, high), 0.5);
511 }
512
513 #[test]
514 fn two_minimums_that_do_not_fit_leave_no_room_to_move() {
515 let (low, high) = limits(100.0, 80.0, 80.0);
516 assert_eq!(low, high, "the divider has nowhere to go");
517 assert_eq!(ratio_at(0.0, 0.0, 100.0, low, high), low);
518 assert_eq!(ratio_at(100.0, 0.0, 100.0, low, high), low);
519 }
520
521 #[test]
522 fn an_unmeasured_split_reports_the_whole_range() {
523 assert_eq!(limits(0.0, 100.0, 100.0), (0.0, 1.0));
524 }
525
526 #[test]
527 fn a_double_click_collapses_the_smaller_pane() {
528 assert_eq!(collapse_target(0.2), SplitSide::Start);
529 assert_eq!(collapse_target(0.9), SplitSide::End);
530 assert_eq!(collapse_target(0.5), SplitSide::Start);
531 }
532}