Skip to main content

gpui_base/resizable/
resize_handle.rs

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