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}