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