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}