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, Hitbox, HitboxBehavior,
5    InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
6    ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window,
7    div, prelude::FluentBuilder as _, px,
8};
9
10use crate::{AxisExt as _, theme::ActiveTheme as _};
11
12pub(crate) const HANDLE_PADDING: Pixels = px(4.);
13pub(crate) const HANDLE_SIZE: Pixels = px(1.);
14
15/// Create a resize handle for a resizable panel.
16#[doc(hidden)]
17pub fn resize_handle<T: 'static, E: 'static + Render>(
18    id: impl Into<ElementId>,
19    axis: Axis,
20) -> ResizeHandle<T, E> {
21    ResizeHandle::new(id, axis)
22}
23
24/// Draws the visible part of a resize handle.
25///
26/// Returning `None` keeps the built-in line, so a renderer can override some
27/// handles and leave the rest alone.
28pub type ResizeHandleRenderer =
29    Rc<dyn Fn(&ResizeHandleContext, &mut Window, &mut App) -> Option<AnyElement>>;
30
31/// What a [`ResizeHandleRenderer`] is told about the handle it is drawing.
32///
33/// The hit area, the cursor and the drag itself stay with the handle; a
34/// renderer only supplies what is painted inside it.
35pub struct ResizeHandleContext {
36    axis: Axis,
37    edge: Option<HandleEdge>,
38    state: ResizeHandleState,
39}
40
41impl ResizeHandleContext {
42    /// The axis the handle resizes along: `Horizontal` for a vertical divider
43    /// between two side-by-side panels.
44    pub fn axis(&self) -> Axis {
45        self.axis
46    }
47
48    /// The edge of its container this handle hugs, or `None` for a handle
49    /// straddling the boundary it resizes.
50    ///
51    /// A hugging handle's line is the container's outermost pixel, so anything
52    /// a renderer centres on it crosses the boundary; see [`HandleEdge`] for
53    /// what the container does with that.
54    pub fn edge(&self) -> Option<HandleEdge> {
55        self.edge
56    }
57
58    /// Whether the pointer currently owns this handle.
59    pub fn is_active(&self) -> bool {
60        self.state.is_active()
61    }
62
63    /// How far the pointer has gone with this handle.
64    pub fn state(&self) -> ResizeHandleState {
65        self.state
66    }
67}
68
69/// How far the pointer has gone with a resize handle.
70///
71/// A drag takes the pointer out of the handle's own band within a pixel or
72/// two, so GPUI's hover reads false for most of a drag and cannot stand in for
73/// `Dragging`. Base tracks the progression instead, and a renderer reads it
74/// through [`ResizeHandleContext::state`].
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub enum ResizeHandleState {
77    /// The pointer is somewhere else.
78    #[default]
79    Idle,
80    /// The pointer is over the handle's band.
81    Hovered,
82    /// The pointer went down on the handle and has not moved since.
83    Pressed,
84    /// The handle is being dragged.
85    Dragging,
86}
87
88impl ResizeHandleState {
89    /// Whether the pointer owns the handle -- pressed on it, or dragging it.
90    pub fn is_active(self) -> bool {
91        matches!(self, Self::Pressed | Self::Dragging)
92    }
93}
94
95/// Which edge of its own container a handle hugs.
96///
97/// A handle named no edge straddles the boundary it resizes, half its band on
98/// either side, which is what a divider between two panels of a group wants.
99/// A dock's own edge handle cannot: [`dock_frame`] clips to the dock's box, so
100/// the half hanging outside is cut away -- what it paints and what it
101/// hit-tests alike, which is why the outer half of a dock's grab band has
102/// never actually been grabbable. Naming the edge moves the whole band inside.
103///
104/// The hairline stays on the boundary itself: it is the container's outermost
105/// pixel, the one the neighbour's content butts up against. Moving it inward
106/// by even a pixel leaves that pixel of the container showing past the line
107/// on one side, or a gap before it on the other, along the whole seam. What a
108/// renderer paints on top of the line -- an indicator thicker than the line,
109/// centred on it -- overhangs the boundary, and the container's clip takes
110/// the outer half off it unless the renderer defers that part. Only that part:
111/// a deferred element paints after the whole tree, and no priority puts it
112/// beneath the application's own deferred content, so a deferred line would
113/// cut straight through a popover opened at the default priority from a panel
114/// drawn before this container. A renderer learns which edge it is drawing for
115/// from [`ResizeHandleContext::edge`].
116///
117/// [`dock_frame`]: crate::dock::dock_frame
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum HandleEdge {
120    /// Where the axis starts: the left edge of a horizontal handle's
121    /// container, the top edge of a vertical one's.
122    Leading,
123    /// Where the axis ends.
124    Trailing,
125}
126
127#[doc(hidden)]
128pub struct ResizeHandle<T: 'static, E: 'static + Render> {
129    id: ElementId,
130    axis: Axis,
131    drag_value: Option<Rc<T>>,
132    edge: Option<HandleEdge>,
133    on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
134    appearance: Option<ResizeHandleRenderer>,
135}
136
137impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
138    fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
139        let id = id.into();
140        Self {
141            id: id.clone(),
142            on_drag: None,
143            drag_value: None,
144            edge: None,
145            appearance: None,
146            axis,
147        }
148    }
149
150    /// Hand the painted part of this handle to `appearance`.
151    pub fn with_appearance(mut self, appearance: ResizeHandleRenderer) -> Self {
152        self.appearance = Some(appearance);
153        self
154    }
155
156    pub fn on_drag(
157        mut self,
158        value: T,
159        f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
160    ) -> Self {
161        let value = Rc::new(value);
162        self.drag_value = Some(value.clone());
163        self.on_drag = Some(Rc::new(move |p, window, cx| {
164            f(value.clone(), p, window, cx)
165        }));
166        self
167    }
168
169    /// Keep the whole handle inside its container, hugging `edge`, instead of
170    /// straddling the boundary it resizes.
171    pub fn inside(mut self, edge: HandleEdge) -> Self {
172        self.edge = Some(edge);
173        self
174    }
175}
176
177/// One handle's [`ResizeHandleState`], shared between the element and the
178/// mouse listeners it registers.
179///
180/// The `Rc` is load-bearing. `with_element_state` hands back a clone and a
181/// bare `Cell` clones by value, so a listener holding one wrote its progress
182/// into a copy that died with the event: the stored state never left `Idle`
183/// and `is_active` never once read true.
184#[derive(Default, Debug, Clone)]
185struct SharedHandleState {
186    state: Rc<Cell<ResizeHandleState>>,
187}
188
189impl SharedHandleState {
190    fn get(&self) -> ResizeHandleState {
191        self.state.get()
192    }
193
194    /// Reports whether the state actually changed, so a listener repaints the
195    /// window only when there is something new to paint.
196    fn set(&self, state: ResizeHandleState) -> bool {
197        let changed = self.state.get() != state;
198        self.state.set(state);
199        changed
200    }
201}
202
203impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
204    type Element = ResizeHandle<T, E>;
205    fn into_element(self) -> Self::Element {
206        self
207    }
208}
209
210impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
211    type RequestLayoutState = AnyElement;
212    /// The band's own hitbox, so the listeners in `paint` can ask whether the
213    /// pointer is really on the handle rather than merely within its bounds.
214    type PrepaintState = Hitbox;
215
216    fn id(&self) -> Option<ElementId> {
217        Some(self.id.clone())
218    }
219
220    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
221        None
222    }
223
224    fn request_layout(
225        &mut self,
226        id: Option<&GlobalElementId>,
227        _: Option<&gpui::InspectorElementId>,
228        window: &mut Window,
229        cx: &mut App,
230    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
231        let neg_offset = -HANDLE_PADDING;
232        let axis = self.axis;
233        let edge = self.edge;
234        // Sizes are border-box: the extent has to name the whole band, padding
235        // included, or the content box resolves to zero and the hairline
236        // overflows into the padding, landing wherever the padding happens to
237        // push it.
238        let hug_extent = HANDLE_SIZE + HANDLE_PADDING;
239        let straddle_extent = HANDLE_SIZE + HANDLE_PADDING * 2.;
240
241        window.with_element_state(id.unwrap(), |state, window| {
242            let state: SharedHandleState = state.unwrap_or_default();
243
244            let bg_color = handle_color(&cx.theme(), state.get().is_active());
245
246            let mut el = div()
247                .id(self.id.clone())
248                .occlude()
249                .absolute()
250                .flex_shrink_0()
251                .group("handle")
252                .when_some(self.on_drag.clone(), |this, on_drag| {
253                    this.on_drag(
254                        self.drag_value.clone().unwrap(),
255                        move |_, position, window, cx| on_drag(&position, window, cx),
256                    )
257                })
258                .map(|this| match (edge, axis) {
259                    // Hugging an edge: the whole band is inside the container,
260                    // padded on the inner side only, so the hairline is the
261                    // container's outermost pixel -- the seam itself.
262                    // FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
263                    (Some(HandleEdge::Trailing), Axis::Horizontal) => this
264                        .cursor_col_resize()
265                        .top_0()
266                        .right_0()
267                        .h_full()
268                        .w(hug_extent)
269                        .pl(HANDLE_PADDING),
270                    (Some(HandleEdge::Leading), Axis::Horizontal) => this
271                        .cursor_col_resize()
272                        .top_0()
273                        .left_0()
274                        .h_full()
275                        .w(hug_extent)
276                        .pr(HANDLE_PADDING),
277                    (Some(HandleEdge::Trailing), Axis::Vertical) => this
278                        .cursor_row_resize()
279                        .bottom_0()
280                        .left_0()
281                        .w_full()
282                        .h(hug_extent)
283                        .pt(HANDLE_PADDING),
284                    (Some(HandleEdge::Leading), Axis::Vertical) => this
285                        .cursor_row_resize()
286                        .top_0()
287                        .left_0()
288                        .w_full()
289                        .h(hug_extent)
290                        .pb(HANDLE_PADDING),
291                    // Straddling the boundary: half the band on either side.
292                    (None, Axis::Horizontal) => this
293                        .cursor_col_resize()
294                        .top_0()
295                        .left(neg_offset)
296                        .h_full()
297                        .w(straddle_extent)
298                        .px(HANDLE_PADDING),
299                    (None, Axis::Vertical) => this
300                        .cursor_row_resize()
301                        .top(neg_offset)
302                        .left_0()
303                        .w_full()
304                        .h(straddle_extent)
305                        .py(HANDLE_PADDING),
306                })
307                .child(
308                    // A renderer that declines — or is absent — leaves the
309                    // built-in line, so overriding one handle never obliges a
310                    // caller to redraw them all. What it paints stays in tree
311                    // order, under the container's own mask: a divider is
312                    // part of the panel it edges, and anything the
313                    // application floats over that panel has to cover it.
314                    self.appearance
315                        .as_ref()
316                        .and_then(|appearance| {
317                            appearance(
318                                &ResizeHandleContext {
319                                    axis,
320                                    edge,
321                                    state: state.get(),
322                                },
323                                window,
324                                cx,
325                            )
326                        })
327                        .unwrap_or_else(|| {
328                            div()
329                                // The line fills the handle's content box
330                                // exactly, so it has nothing to give: a
331                                // shrinkable child collapses with it.
332                                .flex_none()
333                                .bg(bg_color)
334                                .group_hover("handle", |this| this.bg(bg_color))
335                                .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
336                                .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
337                                .into_any_element()
338                        }),
339                )
340                .into_any_element();
341
342            let layout_id = el.request_layout(window, cx);
343
344            ((layout_id, el), state)
345        })
346    }
347
348    fn prepaint(
349        &mut self,
350        _: Option<&GlobalElementId>,
351        _: Option<&gpui::InspectorElementId>,
352        bounds: gpui::Bounds<Pixels>,
353        request_layout: &mut Self::RequestLayoutState,
354        window: &mut Window,
355        cx: &mut App,
356    ) -> Self::PrepaintState {
357        request_layout.prepaint(window, cx);
358        // After the child, deliberately. The band's own div occludes, and a
359        // hit test stops at the first hitbox that does, so a hitbox inserted
360        // before it would sit underneath and never read as hovered. Inserted
361        // here it sits directly on top of the band and directly under whatever
362        // is painted after this handle -- a sheet's overlay, a toast -- which
363        // is exactly the ordering `is_hovered_at` should answer from.
364        window.insert_hitbox(bounds, HitboxBehavior::Normal)
365    }
366
367    fn paint(
368        &mut self,
369        id: Option<&GlobalElementId>,
370        _: Option<&gpui::InspectorElementId>,
371        _: gpui::Bounds<Pixels>,
372        request_layout: &mut Self::RequestLayoutState,
373        hitbox: &mut Self::PrepaintState,
374        window: &mut Window,
375        cx: &mut App,
376    ) {
377        request_layout.paint(window, cx);
378
379        // Hovered and pressed are answered by the hitbox, not by the bounds.
380        // Bounds containment reads true through anything painted over the
381        // handle, so a divider under a sheet lit up as the pointer crossed
382        // where it lay; the hitbox is occluded by that sheet and says no.
383        let hitbox = hitbox.clone();
384
385        window.with_element_state(id.unwrap(), |state: Option<SharedHandleState>, window| {
386            let state = state.unwrap_or_default();
387
388            window.on_mouse_event({
389                let state = state.clone();
390                let hitbox = hitbox.clone();
391                move |ev: &MouseDownEvent, phase, window, _| {
392                    if phase.bubble()
393                        && hitbox.is_hovered_at(ev.position, window)
394                        && state.set(ResizeHandleState::Pressed)
395                    {
396                        window.refresh();
397                    }
398                }
399            });
400
401            window.on_mouse_event({
402                let state = state.clone();
403                let hitbox = hitbox.clone();
404                move |ev: &MouseMoveEvent, phase, window, _| {
405                    if !phase.bubble() {
406                        return;
407                    }
408
409                    // A press that moves is a drag, and stays one until the
410                    // button comes back up: by the second frame the pointer is
411                    // outside this nine-pixel band, so where it is says nothing
412                    // about whether the handle is still being dragged.
413                    let next = match state.get() {
414                        engaged if engaged.is_active() => ResizeHandleState::Dragging,
415                        _ if hitbox.is_hovered_at(ev.position, window) => {
416                            ResizeHandleState::Hovered
417                        }
418                        _ => ResizeHandleState::Idle,
419                    };
420                    if state.set(next) {
421                        window.refresh();
422                    }
423                }
424            });
425
426            window.on_mouse_event({
427                let state = state.clone();
428                let hitbox = hitbox.clone();
429                move |ev: &MouseUpEvent, _, window, _| {
430                    if !state.get().is_active() {
431                        return;
432                    }
433
434                    // Releasing over the handle leaves it hovered. Going
435                    // straight to idle there would drop the indicator for one
436                    // frame and bring it back under a pointer that never left.
437                    let next = if hitbox.is_hovered_at(ev.position, window) {
438                        ResizeHandleState::Hovered
439                    } else {
440                        ResizeHandleState::Idle
441                    };
442                    if state.set(next) {
443                        window.refresh();
444                    }
445                }
446            });
447
448            ((), state)
449        });
450    }
451}
452
453/// What a resize handle paints, given the active theme.
454///
455/// Projected colors win; without them the handle resolves from the tokens that
456/// already mean these two states everywhere else -- `border` for a divider at
457/// rest, `ring` for the thing the pointer currently owns. Before this the
458/// unprojected answer was `Hsla::default()`, which is transparent, so a
459/// consumer with no styled façade had no divider at all.
460pub(crate) fn handle_color(theme: &crate::Theme, active: bool) -> gpui::Hsla {
461    if active {
462        theme
463            .resizable
464            .active_handle
465            .unwrap_or(theme.tokens.colors.ring)
466    } else {
467        theme.resizable.handle.unwrap_or(theme.tokens.colors.border)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use std::{cell::Cell, rc::Rc};
474
475    use gpui::{
476        AnyElement, App, Axis, Bounds, Context, Empty, IntoElement, ParentElement as _, Pixels,
477        Render, Styled as _, TestAppContext, Window, deferred, div, hsla,
478        prelude::FluentBuilder as _, px,
479    };
480
481    use super::{
482        HandleEdge, ResizeHandleContext, ResizeHandleState, SharedHandleState, handle_color,
483        resize_handle,
484    };
485    use crate::{ElementExt as _, ResizableTheme, Theme};
486
487    /// What a hugging handle's renderer was told and drew, and where the
488    /// drawing landed in the frame: under which mask, and before or after a
489    /// popover the application deferred from a panel drawn ahead of the dock.
490    #[derive(Default)]
491    struct Probe {
492        edge: Cell<Option<Option<HandleEdge>>>,
493        line: Cell<Option<Bounds<Pixels>>>,
494        mask: Cell<Option<Bounds<Pixels>>>,
495        /// Ticked by every prepaint hook below, in the order the frame reaches
496        /// them.
497        prepaints: Cell<usize>,
498        line_prepainted: Cell<Option<usize>>,
499        popover_prepainted: Cell<Option<usize>>,
500    }
501
502    impl Probe {
503        fn tick(&self) -> usize {
504            let n = self.prepaints.get();
505            self.prepaints.set(n + 1);
506            n
507        }
508    }
509
510    /// A hairline filling the handle's content box, the way a styled divider
511    /// rests.
512    fn hairline(axis: Axis, probe: Rc<Probe>) -> AnyElement {
513        div()
514            .flex_none()
515            .map(|line| match axis {
516                Axis::Horizontal => line.w(px(1.)).h_full(),
517                Axis::Vertical => line.h(px(1.)).w_full(),
518            })
519            .on_prepaint(move |bounds, window, _| {
520                probe.line.set(Some(bounds));
521                probe.mask.set(Some(window.content_mask().bounds));
522                probe.line_prepainted.set(Some(probe.tick()));
523            })
524            .into_any_element()
525    }
526
527    /// A drag payload for a handle nobody drags in these tests.
528    struct NoDrag;
529
530    impl Render for NoDrag {
531        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
532            Empty
533        }
534    }
535
536    /// A dock-shaped box: 200px along the axis, clipped to itself the way
537    /// `dock_frame` is, sitting between two 100px neighbours so both of its
538    /// edges are seams. The handle hugs one of them.
539    ///
540    /// The first neighbour floats a popover across both seams, deferred at the
541    /// default priority the way an application's own `deferred(anchored())`
542    /// is -- a panel drawn before the dock, opening something over it.
543    struct HuggingHarness {
544        axis: Axis,
545        edge: HandleEdge,
546        probe: Rc<Probe>,
547    }
548
549    impl Render for HuggingHarness {
550        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
551            let axis = self.axis;
552            let probe = self.probe.clone();
553            let neighbour = || match axis {
554                Axis::Horizontal => div().w(px(100.)).h_full(),
555                Axis::Vertical => div().h(px(100.)).w_full(),
556            };
557            let popover = div()
558                .absolute()
559                .map(|popover| match axis {
560                    Axis::Horizontal => popover.top_0().left(px(50.)).w(px(300.)).h_full(),
561                    Axis::Vertical => popover.left_0().top(px(50.)).h(px(300.)).w_full(),
562                })
563                .on_prepaint({
564                    let probe = probe.clone();
565                    move |_, _, _| probe.popover_prepainted.set(Some(probe.tick()))
566                });
567            div()
568                .flex()
569                .map(|row| match axis {
570                    Axis::Horizontal => row.flex_row().w(px(400.)).h(px(100.)),
571                    Axis::Vertical => row.flex_col().h(px(400.)).w(px(100.)),
572                })
573                .child(neighbour().child(deferred(popover)))
574                .child(
575                    div()
576                        .relative()
577                        .overflow_hidden()
578                        .map(|dock| match axis {
579                            Axis::Horizontal => dock.w(px(200.)).h_full(),
580                            Axis::Vertical => dock.h(px(200.)).w_full(),
581                        })
582                        .child(
583                            resize_handle::<(), NoDrag>("hugging", axis)
584                                .inside(self.edge)
585                                .with_appearance(Rc::new(
586                                    move |handle: &ResizeHandleContext,
587                                          _: &mut Window,
588                                          _: &mut App| {
589                                        probe.edge.set(Some(handle.edge()));
590                                        Some(hairline(handle.axis(), probe.clone()))
591                                    },
592                                )),
593                        ),
594                )
595                .child(neighbour())
596        }
597    }
598
599    fn draw_hugging(cx: &mut TestAppContext, axis: Axis, edge: HandleEdge) -> Rc<Probe> {
600        let probe = Rc::new(Probe::default());
601        let (_, cx) = cx.add_window_view({
602            let probe = probe.clone();
603            move |_, _| HuggingHarness { axis, edge, probe }
604        });
605        cx.update(|window, cx| window.draw(cx).clear(cx));
606        probe
607    }
608
609    /// The seam a hugging handle marks, along its axis.
610    ///
611    /// The dock spans 100..300 in the harness, so its leading seam is at 100
612    /// and its trailing one at 300.
613    fn seam(edge: HandleEdge) -> Pixels {
614        match edge {
615            HandleEdge::Leading => px(100.),
616            HandleEdge::Trailing => px(300.),
617        }
618    }
619
620    fn along(axis: Axis, bounds: Bounds<Pixels>) -> (Pixels, Pixels) {
621        match axis {
622            Axis::Horizontal => (bounds.left(), bounds.right()),
623            Axis::Vertical => (bounds.top(), bounds.bottom()),
624        }
625    }
626
627    /// A hugging handle's hairline is the container's outermost pixel.
628    ///
629    /// This is the regression. The line was set one pixel in from the
630    /// boundary, to make room for an indicator to overhang it, and along the
631    /// whole seam that pixel of the dock showed past the line on one side of
632    /// the area and opened a gap before it on the other.
633    #[gpui::test]
634    fn a_hugging_handle_draws_its_line_on_the_seam(cx: &mut TestAppContext) {
635        for axis in [Axis::Horizontal, Axis::Vertical] {
636            for edge in [HandleEdge::Leading, HandleEdge::Trailing] {
637                let probe = draw_hugging(cx, axis, edge);
638                let line = probe.line.get().expect("the renderer was asked to draw");
639                let (start, end) = along(axis, line);
640                let expected = match edge {
641                    HandleEdge::Leading => (seam(edge), seam(edge) + px(1.)),
642                    HandleEdge::Trailing => (seam(edge) - px(1.), seam(edge)),
643                };
644                assert_eq!(
645                    (start, end),
646                    expected,
647                    "{axis:?} {edge:?}: the hairline has to be the pixel against the seam"
648                );
649            }
650        }
651    }
652
653    /// A hugging handle's appearance is painted in tree order, under its
654    /// container's mask -- beneath whatever the application floats over the
655    /// panels around it.
656    ///
657    /// This is the regression. The appearance was deferred so that an
658    /// indicator centred on the hairline would keep the pixel overhanging the
659    /// container, and a deferred element paints after the whole tree at a
660    /// priority no lower than the default. A popover an application defers
661    /// from a panel drawn before the dock -- at that default priority, as
662    /// `deferred(anchored())` is -- was painted first, and the dock's divider
663    /// ran straight through it.
664    #[gpui::test]
665    fn a_hugging_handle_paints_beneath_a_popover_deferred_before_it(cx: &mut TestAppContext) {
666        for axis in [Axis::Horizontal, Axis::Vertical] {
667            for edge in [HandleEdge::Leading, HandleEdge::Trailing] {
668                let probe = draw_hugging(cx, axis, edge);
669                let line = probe
670                    .line_prepainted
671                    .get()
672                    .expect("the line was prepainted");
673                let popover = probe
674                    .popover_prepainted
675                    .get()
676                    .expect("the popover was prepainted");
677                assert!(
678                    line < popover,
679                    "{axis:?} {edge:?}: the line (prepaint #{line}) has to go down before \
680                     the popover (#{popover}), or it is painted over it"
681                );
682
683                // The dock spans 100..300 along the axis; a deferred line would
684                // have been prepainted under the window's mask instead.
685                let mask = probe.mask.get().expect("the line was prepainted");
686                assert_eq!(
687                    along(axis, mask),
688                    (px(100.), px(300.)),
689                    "{axis:?} {edge:?}: the line is painted under its container's clip"
690                );
691            }
692        }
693    }
694
695    /// A renderer is told which edge the handle hugs, so it can keep what it
696    /// centres on the line clear of the container's clip itself.
697    #[gpui::test]
698    fn a_renderer_is_told_the_edge_a_handle_hugs(cx: &mut TestAppContext) {
699        for axis in [Axis::Horizontal, Axis::Vertical] {
700            for edge in [HandleEdge::Leading, HandleEdge::Trailing] {
701                let probe = draw_hugging(cx, axis, edge);
702                assert_eq!(probe.edge.get(), Some(Some(edge)), "{axis:?} {edge:?}");
703            }
704        }
705    }
706
707    #[test]
708    fn a_listener_writes_its_progress_back_into_the_stored_state() {
709        let stored = SharedHandleState::default();
710        // What `paint` hands each mouse listener.
711        let listener = stored.clone();
712
713        assert!(listener.set(ResizeHandleState::Pressed));
714
715        // The regression this pins down: the state used to be a bare `Cell`,
716        // which clones by value, so a listener wrote into a copy that died
717        // with the event and the handle never left `Idle`.
718        assert_eq!(stored.get(), ResizeHandleState::Pressed);
719        assert!(stored.get().is_active());
720    }
721
722    #[test]
723    fn setting_the_state_it_already_has_asks_for_no_repaint() {
724        let state = SharedHandleState::default();
725
726        assert!(state.set(ResizeHandleState::Hovered));
727        assert!(!state.set(ResizeHandleState::Hovered));
728    }
729
730    #[test]
731    fn only_a_held_handle_is_active() {
732        assert!(!ResizeHandleState::Idle.is_active());
733        assert!(!ResizeHandleState::Hovered.is_active());
734        assert!(ResizeHandleState::Pressed.is_active());
735        assert!(ResizeHandleState::Dragging.is_active());
736    }
737
738    #[gpui::test]
739    fn an_unprojected_handle_resolves_from_the_theme_tokens(cx: &mut TestAppContext) {
740        cx.update(|cx| {
741            let border = hsla(0., 0., 0.5, 1.0);
742            let ring = hsla(0.6, 0.5, 0.5, 1.0);
743            let theme = Theme::global_mut(cx);
744            theme.tokens.colors.border = border;
745            theme.tokens.colors.ring = ring;
746            theme.resizable = ResizableTheme::default();
747
748            let theme = Theme::global(cx);
749            assert_eq!(handle_color(&theme, false), border);
750            assert_eq!(handle_color(&theme, true), ring);
751            // The point of the change: the default used to be transparent, so
752            // a divider with nothing projected onto it was not drawn at all.
753            assert_ne!(handle_color(&theme, false), gpui::Hsla::default());
754        });
755    }
756
757    #[gpui::test]
758    fn a_projected_handle_still_wins(cx: &mut TestAppContext) {
759        cx.update(|cx| {
760            let projected = hsla(0.3, 0.4, 0.5, 1.0);
761            let active = hsla(0.9, 0.4, 0.5, 1.0);
762            let theme = Theme::global_mut(cx);
763            theme.tokens.colors.border = hsla(0., 0., 0.5, 1.0);
764            theme.resizable = ResizableTheme {
765                handle: Some(projected),
766                active_handle: Some(active),
767            };
768
769            let theme = Theme::global(cx);
770            assert_eq!(handle_color(&theme, false), projected);
771            assert_eq!(handle_color(&theme, true), active);
772        });
773    }
774}