Skip to main content

gpui_component/
resizable.rs

1//! What this design system paints inside a resize handle.
2//!
3//! Base owns the band, the cursor and the drag; everything here is appearance.
4//! A divider rests as the same hairline it has always been, and answers the
5//! pointer with a pill that grows and solidifies as the pointer engages it:
6//! available, held, being dragged.
7
8use std::rc::Rc;
9
10use gpui::{
11    AnyElement, App, Axis, ElementId, IntoElement, ParentElement as _, Pixels, Styled as _, Window,
12    deferred, div, prelude::FluentBuilder as _, px,
13};
14use gpui_base::{
15    ResizeHandleContext, ResizeHandleRenderer, ResizeHandleState, Transition, transition,
16};
17
18pub use gpui_base::{
19    ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, resizable_panel,
20};
21
22use crate::theme::ActiveTheme as _;
23
24/// How thick the indicator is across the divider it sits on.
25const INDICATOR_THICKNESS: Pixels = px(3.);
26
27/// Create a [`ResizablePanelGroup`] with horizontal resizing.
28pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
29    gpui_base::h_resizable(id).with_handle_appearance(resize_handle_appearance())
30}
31
32/// Create a [`ResizablePanelGroup`] with vertical resizing.
33pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
34    gpui_base::v_resizable(id).with_handle_appearance(resize_handle_appearance())
35}
36
37/// This design system's divider appearance, for a handle that base does not
38/// already hand it — a dock edge, or a hand-rolled handle in an application.
39pub fn resize_handle_appearance() -> ResizeHandleRenderer {
40    Rc::new(|handle, window, cx| Some(render_resize_handle(handle, window, cx)))
41}
42
43/// How long the indicator is at each level of engagement, and how solid.
44///
45/// Idle draws nothing. The hairline is the resting appearance of a divider, and
46/// a pill on every divider all the time would be noise in a dock that has a
47/// dozen of them.
48fn indicator(state: ResizeHandleState) -> (Pixels, f32) {
49    match state {
50        ResizeHandleState::Idle => (px(0.), 0.),
51        ResizeHandleState::Hovered => (px(20.), 0.35),
52        ResizeHandleState::Pressed => (px(28.), 0.6),
53        ResizeHandleState::Dragging => (px(44.), 0.9),
54    }
55}
56
57/// The hairline, and the indicator riding on it.
58pub(crate) fn render_resize_handle(
59    handle: &ResizeHandleContext,
60    window: &mut Window,
61    cx: &mut App,
62) -> AnyElement {
63    let axis = handle.axis();
64    let (target_length, target_opacity) = indicator(handle.state());
65    let motion = cx.theme().motion_tokens();
66    let policy = Transition::new(motion.duration_fast).easing(motion.easing_move.clone());
67
68    // Both values are sampled on every frame the handle is rendered, whatever
69    // it is showing. A transition asks for a frame only while it is moving, so
70    // a resting handle costs nothing; sampling it only while the pill is up
71    // would instead leave the retained value frozen wherever it was when the
72    // pill went away, and the next hover would start from there.
73    let length = transition(
74        "resizable-handle-indicator-length",
75        target_length,
76        policy.clone(),
77        window,
78        cx,
79    );
80    let opacity = transition(
81        "resizable-handle-indicator-opacity",
82        target_opacity,
83        policy,
84        window,
85        cx,
86    );
87
88    div()
89        // The hairline fills the handle's content area exactly, so it has
90        // nothing to give: shrinking it collapses the divider.
91        .flex_none()
92        .flex()
93        .bg(cx.theme().border)
94        // Along the hairline the pill is far shorter than the line, so centring
95        // it there is safe. Across the hairline it is thicker than the line and
96        // has to overhang, and neither flex alignment can be trusted to centre
97        // an item that overflows -- `justify_center` returns it to the start
98        // instead -- so that axis is offset by hand, below.
99        .map(|line| match axis {
100            Axis::Horizontal => line.w(px(1.)).h_full().items_center(),
101            _ => line.h(px(1.)).w_full().items_start().justify_center(),
102        })
103        .when(length > px(0.5), |line| {
104            let pill = div()
105                // `flex_none` keeps the one-pixel line from squashing it.
106                .flex_none()
107                .rounded(cx.theme().radius_full())
108                .bg(cx.theme().muted_foreground)
109                .opacity(opacity)
110                // Half the overhang, pulled back so the pill straddles the
111                // hairline evenly.
112                .map(|pill| match axis {
113                    Axis::Horizontal => pill
114                        .w(INDICATOR_THICKNESS)
115                        .h(length)
116                        .ml((INDICATOR_THICKNESS - px(1.)) * -0.5),
117                    _ => pill
118                        .h(INDICATOR_THICKNESS)
119                        .w(length)
120                        .mt((INDICATOR_THICKNESS - px(1.)) * -0.5),
121                });
122            // A hugging handle's hairline is its container's outermost pixel,
123            // so the pill's outer pixel lies past the boundary, where a dock's
124            // clip would take it off. Deferring the pill -- and only the pill,
125            // only while it is up -- paints it after the tree under the
126            // window's mask, so it keeps that pixel. The hairline stays in
127            // tree order: a deferred element paints over the application's own
128            // deferred content, and a divider that cut through a popover
129            // opened from the neighbouring panel is what that looked like.
130            line.child(match handle.edge() {
131                Some(_) => deferred(pill).into_any_element(),
132                None => pill.into_any_element(),
133            })
134        })
135        .into_any_element()
136}