Skip to main content

gpui_kit/layout/
split.rs

1//! Two panes and a divider the typist can move.
2//!
3//! Where the divider sits is the caller's answer, not the component's. The
4//! split reports the ratio a drag or a keystroke asked for and draws whatever
5//! the caller says is true, so a host that refuses a resize keeps the layout
6//! that still holds.
7
8use 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
24/// How wide the grab area of the divider is, and how long the grip drawn
25/// inside it is. Neither value repeats anywhere else.
26pub(crate) const HANDLE: f32 = 7.0;
27const GRIP: f32 = 24.0;
28
29/// How far one arrow key moves the divider when the caller says nothing.
30const 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/// Which way the two panes are laid out.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SplitAxis {
38    /// Side by side, with a vertical divider.
39    #[default]
40    Horizontal,
41    /// Stacked, with a horizontal divider.
42    Vertical,
43}
44
45/// One of the two panes.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum SplitSide {
48    /// Left in a horizontal split, top in a vertical one.
49    Start,
50    /// Right in a horizontal split, bottom in a vertical one.
51    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/// Two panes separated by a divider.
64#[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    /// How much of the split the first pane takes, from 0 to 1.
129    pub fn ratio(mut self, ratio: f32) -> Self {
130        self.ratio = ratio.clamp(0.0, 1.0);
131        self
132    }
133
134    /// The smallest each pane may be dragged to, in pixels.
135    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    /// How far one arrow key moves the divider, in pixels.
142    pub fn step(mut self, step: f32) -> Self {
143        if step > 0.0 {
144            self.step = step;
145        }
146        self
147    }
148
149    /// Whether a double click on the divider reports a collapse.
150    pub fn collapsible(mut self, collapsible: bool) -> Self {
151        self.collapsible = collapsible;
152        self
153    }
154
155    /// What the divider is called, for a reader that has only the tree.
156    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    /// Reports the pane a double click on the divider asked to collapse.
177    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    /// Freezes the divider. A frozen split installs no handler at all,
188    /// including the keyboard one.
189    fn disabled(mut self, disabled: bool) -> Self {
190        self.disabled = disabled;
191        self
192    }
193}
194
195/// The narrowest and widest the first pane may be, as a fraction of `extent`.
196///
197/// Two minimums that together do not fit leave no room to move at all, so the
198/// range collapses onto the point that splits the difference rather than
199/// producing a reversed one.
200pub(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
213/// The ratio a pointer at `position` asks for, stopped at whichever minimum it
214/// runs into.
215pub(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
222/// Which pane a double click asks to collapse.
223///
224/// The smaller of the two, because that is the one the divider already stands
225/// nearest and the one collapsing costs the least.
226pub(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        // The divider needs the split's measured extent to turn a pointer
243        // position into a ratio, and only prepaint knows it.
244        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        // A drag has to be followed across the whole split, not just the few
283        // pixels of the handle, so the divider only records that one started
284        // and the frame does the following.
285        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        // A pane with no share of the split drops its content rather than
385        // drawing it at zero size, so nothing invisible stays addressable.
386        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                // A pane already at its minimum has nowhere left to go, so
435                // dragging further reports nothing rather than a ratio the
436                // caller would only have to clamp itself.
437                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
471/// Whether a drag of this split's divider is in flight.
472///
473/// A `RenderOnce` builder is rebuilt every frame, so the press that starts a
474/// drag and the moves that continue it are in different builds and need
475/// somewhere outside the frame to agree.
476fn 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}