Skip to main content

gpui_base/resizable/
resize_handle.rs

1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4    AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
5    IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
6    StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px,
7};
8
9use crate::{AxisExt as _, Side, theme::ActiveTheme as _};
10
11pub(crate) const HANDLE_PADDING: Pixels = px(4.);
12pub(crate) const HANDLE_SIZE: Pixels = px(1.);
13
14/// Create a resize handle for a resizable panel.
15#[doc(hidden)]
16pub fn resize_handle<T: 'static, E: 'static + Render>(
17    id: impl Into<ElementId>,
18    axis: Axis,
19) -> ResizeHandle<T, E> {
20    ResizeHandle::new(id, axis)
21}
22
23/// Draws the visible part of a resize handle.
24///
25/// Returning `None` keeps the built-in line, so a renderer can override some
26/// handles and leave the rest alone.
27pub type ResizeHandleRenderer =
28    Rc<dyn Fn(&ResizeHandleContext, &mut Window, &mut App) -> Option<AnyElement>>;
29
30/// What a [`ResizeHandleRenderer`] is told about the handle it is drawing.
31///
32/// The hit area, the cursor and the drag itself stay with the handle; a
33/// renderer only supplies what is painted inside it.
34pub struct ResizeHandleContext {
35    axis: Axis,
36    active: bool,
37}
38
39impl ResizeHandleContext {
40    /// The axis the handle resizes along: `Horizontal` for a vertical divider
41    /// between two side-by-side panels.
42    pub fn axis(&self) -> Axis {
43        self.axis
44    }
45
46    /// Whether this handle is the one being dragged right now.
47    pub fn is_active(&self) -> bool {
48        self.active
49    }
50}
51
52#[doc(hidden)]
53pub struct ResizeHandle<T: 'static, E: 'static + Render> {
54    id: ElementId,
55    axis: Axis,
56    drag_value: Option<Rc<T>>,
57    placement: Option<Side>,
58    on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
59    appearance: Option<ResizeHandleRenderer>,
60}
61
62impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
63    fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
64        let id = id.into();
65        Self {
66            id: id.clone(),
67            on_drag: None,
68            drag_value: None,
69            placement: None,
70            appearance: None,
71            axis,
72        }
73    }
74
75    /// Hand the painted part of this handle to `appearance`.
76    pub fn with_appearance(mut self, appearance: ResizeHandleRenderer) -> Self {
77        self.appearance = Some(appearance);
78        self
79    }
80
81    pub fn on_drag(
82        mut self,
83        value: T,
84        f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
85    ) -> Self {
86        let value = Rc::new(value);
87        self.drag_value = Some(value.clone());
88        self.on_drag = Some(Rc::new(move |p, window, cx| {
89            f(value.clone(), p, window, cx)
90        }));
91        self
92    }
93
94    pub fn placement(mut self, placement: Side) -> Self {
95        self.placement = Some(placement);
96        self
97    }
98}
99
100#[derive(Default, Debug, Clone)]
101struct ResizeHandleState {
102    active: Cell<bool>,
103}
104
105impl ResizeHandleState {
106    fn set_active(&self, active: bool) {
107        self.active.set(active);
108    }
109
110    fn is_active(&self) -> bool {
111        self.active.get()
112    }
113}
114
115impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
116    type Element = ResizeHandle<T, E>;
117    fn into_element(self) -> Self::Element {
118        self
119    }
120}
121
122impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
123    type RequestLayoutState = AnyElement;
124    type PrepaintState = ();
125
126    fn id(&self) -> Option<ElementId> {
127        Some(self.id.clone())
128    }
129
130    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
131        None
132    }
133
134    fn request_layout(
135        &mut self,
136        id: Option<&GlobalElementId>,
137        _: Option<&gpui::InspectorElementId>,
138        window: &mut Window,
139        cx: &mut App,
140    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
141        let neg_offset = -HANDLE_PADDING;
142        let axis = self.axis;
143
144        window.with_element_state(id.unwrap(), |state, window| {
145            let state = state.unwrap_or(ResizeHandleState::default());
146
147            let bg_color = handle_color(&cx.theme(), state.is_active());
148
149            let mut el = div()
150                .id(self.id.clone())
151                .occlude()
152                .absolute()
153                .flex_shrink_0()
154                .group("handle")
155                .when_some(self.on_drag.clone(), |this, on_drag| {
156                    this.on_drag(
157                        self.drag_value.clone().unwrap(),
158                        move |_, position, window, cx| on_drag(&position, window, cx),
159                    )
160                })
161                .map(|this| match self.placement {
162                    Some(Side::Left) => {
163                        // Special for Left Dock
164                        //  FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
165                        this.cursor_col_resize()
166                            .top_0()
167                            .right(px(1.))
168                            .h_full()
169                            .w(HANDLE_SIZE)
170                            .pl(HANDLE_PADDING)
171                    }
172                    _ => this
173                        .when(axis.is_horizontal(), |this| {
174                            this.cursor_col_resize()
175                                .top_0()
176                                .left(neg_offset)
177                                .h_full()
178                                .w(HANDLE_SIZE)
179                                .px(HANDLE_PADDING)
180                        })
181                        .when(axis.is_vertical(), |this| {
182                            this.cursor_row_resize()
183                                .top(neg_offset)
184                                .left_0()
185                                .w_full()
186                                .h(HANDLE_SIZE)
187                                .py(HANDLE_PADDING)
188                        }),
189                })
190                .child(
191                    // A renderer that declines — or is absent — leaves the
192                    // built-in line, so overriding one handle never obliges a
193                    // caller to redraw them all.
194                    self.appearance
195                        .as_ref()
196                        .and_then(|appearance| {
197                            appearance(
198                                &ResizeHandleContext {
199                                    axis,
200                                    active: state.is_active(),
201                                },
202                                window,
203                                cx,
204                            )
205                        })
206                        .unwrap_or_else(|| {
207                            div()
208                                // The handle's border box is HANDLE_SIZE wide but
209                                // padded by HANDLE_PADDING, so its content area is
210                                // zero and a shrinkable child collapses with it.
211                                .flex_none()
212                                .bg(bg_color)
213                                .group_hover("handle", |this| this.bg(bg_color))
214                                .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
215                                .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
216                                .into_any_element()
217                        }),
218                )
219                .into_any_element();
220
221            let layout_id = el.request_layout(window, cx);
222
223            ((layout_id, el), state)
224        })
225    }
226
227    fn prepaint(
228        &mut self,
229        _: Option<&GlobalElementId>,
230        _: Option<&gpui::InspectorElementId>,
231        _: gpui::Bounds<Pixels>,
232        request_layout: &mut Self::RequestLayoutState,
233        window: &mut Window,
234        cx: &mut App,
235    ) -> Self::PrepaintState {
236        request_layout.prepaint(window, cx);
237    }
238
239    fn paint(
240        &mut self,
241        id: Option<&GlobalElementId>,
242        _: Option<&gpui::InspectorElementId>,
243        bounds: gpui::Bounds<Pixels>,
244        request_layout: &mut Self::RequestLayoutState,
245        _: &mut Self::PrepaintState,
246        window: &mut Window,
247        cx: &mut App,
248    ) {
249        request_layout.paint(window, cx);
250
251        window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
252            let state = state.unwrap_or(ResizeHandleState::default());
253
254            window.on_mouse_event({
255                let state = state.clone();
256                move |ev: &MouseDownEvent, phase, window, _| {
257                    if bounds.contains(&ev.position) && phase.bubble() {
258                        state.set_active(true);
259                        window.refresh();
260                    }
261                }
262            });
263
264            window.on_mouse_event({
265                let state = state.clone();
266                move |_: &MouseUpEvent, _, window, _| {
267                    if state.is_active() {
268                        state.set_active(false);
269                        window.refresh();
270                    }
271                }
272            });
273
274            ((), state)
275        });
276    }
277}
278
279/// What a resize handle paints, given the active theme.
280///
281/// Projected colors win; without them the handle resolves from the tokens that
282/// already mean these two states everywhere else -- `border` for a divider at
283/// rest, `ring` for the thing the pointer currently owns. Before this the
284/// unprojected answer was `Hsla::default()`, which is transparent, so a
285/// consumer with no styled façade had no divider at all.
286pub(crate) fn handle_color(theme: &crate::Theme, active: bool) -> gpui::Hsla {
287    if active {
288        theme
289            .resizable
290            .active_handle
291            .unwrap_or(theme.tokens.colors.ring)
292    } else {
293        theme.resizable.handle.unwrap_or(theme.tokens.colors.border)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use gpui::{TestAppContext, hsla};
300
301    use super::handle_color;
302    use crate::{ResizableTheme, Theme};
303
304    #[gpui::test]
305    fn an_unprojected_handle_resolves_from_the_theme_tokens(cx: &mut TestAppContext) {
306        cx.update(|cx| {
307            let border = hsla(0., 0., 0.5, 1.0);
308            let ring = hsla(0.6, 0.5, 0.5, 1.0);
309            let theme = Theme::global_mut(cx);
310            theme.tokens.colors.border = border;
311            theme.tokens.colors.ring = ring;
312            theme.resizable = ResizableTheme::default();
313
314            let theme = Theme::global(cx);
315            assert_eq!(handle_color(&theme, false), border);
316            assert_eq!(handle_color(&theme, true), ring);
317            // The point of the change: the default used to be transparent, so
318            // a divider with nothing projected onto it was not drawn at all.
319            assert_ne!(handle_color(&theme, false), gpui::Hsla::default());
320        });
321    }
322
323    #[gpui::test]
324    fn a_projected_handle_still_wins(cx: &mut TestAppContext) {
325        cx.update(|cx| {
326            let projected = hsla(0.3, 0.4, 0.5, 1.0);
327            let active = hsla(0.9, 0.4, 0.5, 1.0);
328            let theme = Theme::global_mut(cx);
329            theme.tokens.colors.border = hsla(0., 0., 0.5, 1.0);
330            theme.resizable = ResizableTheme {
331                handle: Some(projected),
332                active_handle: Some(active),
333            };
334
335            let theme = Theme::global(cx);
336            assert_eq!(handle_color(&theme, false), projected);
337            assert_eq!(handle_color(&theme, true), active);
338        });
339    }
340}