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}