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}