use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, Div, Element, ElementId, Entity, GlobalElementId, Hitbox,
HitboxBehavior, InteractiveElement, Interactivity, IntoElement, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement,
Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use crate::{AxisExt as _, InteractiveElementExt, TestSupportExt as _, theme::ActiveTheme as _};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
type DragConstructor<T, E> = Box<dyn Fn(&T, &Point<Pixels>, &mut Window, &mut App) -> Entity<E>>;
pub fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
pub type ResizeHandleRenderer =
Rc<dyn Fn(&ResizeHandleContext, &mut Window, &mut App) -> Option<AnyElement>>;
pub struct ResizeHandleContext {
axis: Axis,
edge: Option<HandleEdge>,
state: ResizeHandleState,
}
impl ResizeHandleContext {
pub fn axis(&self) -> Axis {
self.axis
}
pub fn edge(&self) -> Option<HandleEdge> {
self.edge
}
pub fn is_active(&self) -> bool {
self.state.is_active()
}
pub fn state(&self) -> ResizeHandleState {
self.state
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ResizeHandleState {
#[default]
Idle,
Hovered,
Pressed,
Dragging,
}
impl ResizeHandleState {
pub fn is_active(self) -> bool {
matches!(self, Self::Pressed | Self::Dragging)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HandleEdge {
Leading,
Trailing,
}
pub struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
edge: Option<HandleEdge>,
drag: Option<(T, DragConstructor<T, E>)>,
appearance: Option<ResizeHandleRenderer>,
base: Div,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
edge: None,
drag: None,
appearance: None,
axis,
base: div(),
}
}
pub fn with_appearance(mut self, appearance: ResizeHandleRenderer) -> Self {
self.appearance = Some(appearance);
self
}
pub fn on_drag(
mut self,
value: T,
f: impl Fn(&T, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
self.drag = Some((value, Box::new(f)));
self
}
pub fn inside(mut self, edge: HandleEdge) -> Self {
self.edge = Some(edge);
self
}
}
impl<T: 'static, E: 'static + Render> InteractiveElement for ResizeHandle<T, E> {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl<T: 'static, E: 'static + Render> StatefulInteractiveElement for ResizeHandle<T, E> {}
impl<T: 'static, E: 'static + Render> InteractiveElementExt for ResizeHandle<T, E> {}
#[derive(Default, Debug, Clone)]
struct SharedHandleState {
state: Rc<Cell<ResizeHandleState>>,
}
impl SharedHandleState {
fn get(&self) -> ResizeHandleState {
self.state.get()
}
fn set(&self, state: ResizeHandleState) -> bool {
let changed = self.state.get() != state;
self.state.set(state);
changed
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type RequestLayoutState = AnyElement;
type PrepaintState = Hitbox;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
let edge = self.edge;
let hug_extent = HANDLE_SIZE + HANDLE_PADDING;
let straddle_extent = HANDLE_SIZE + HANDLE_PADDING * 2.;
let base = std::mem::replace(&mut self.base, div());
let drag = self.drag.take();
window.with_element_state(id.unwrap(), |state, window| {
let state: SharedHandleState = state.unwrap_or_default();
let bg_color = handle_color(&cx.theme(), state.get().is_active());
let mut el = base
.id(self.id.clone())
.test_support()
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(drag, |this, (value, on_drag)| {
this.on_drag(value, move |value, position, window, cx| {
on_drag(value, &position, window, cx)
})
})
.map(|this| match (edge, axis) {
(Some(HandleEdge::Trailing), Axis::Horizontal) => this
.cursor_col_resize()
.top_0()
.right_0()
.h_full()
.w(hug_extent)
.pl(HANDLE_PADDING),
(Some(HandleEdge::Leading), Axis::Horizontal) => this
.cursor_col_resize()
.top_0()
.left_0()
.h_full()
.w(hug_extent)
.pr(HANDLE_PADDING),
(Some(HandleEdge::Trailing), Axis::Vertical) => this
.cursor_row_resize()
.bottom_0()
.left_0()
.w_full()
.h(hug_extent)
.pt(HANDLE_PADDING),
(Some(HandleEdge::Leading), Axis::Vertical) => this
.cursor_row_resize()
.top_0()
.left_0()
.w_full()
.h(hug_extent)
.pb(HANDLE_PADDING),
(None, Axis::Horizontal) => this
.cursor_col_resize()
.top_0()
.left(neg_offset)
.h_full()
.w(straddle_extent)
.px(HANDLE_PADDING),
(None, Axis::Vertical) => this
.cursor_row_resize()
.top(neg_offset)
.left_0()
.w_full()
.h(straddle_extent)
.py(HANDLE_PADDING),
})
.child(
self.appearance
.as_ref()
.and_then(|appearance| {
appearance(
&ResizeHandleContext {
axis,
edge,
state: state.get(),
},
window,
cx,
)
})
.unwrap_or_else(|| {
div()
.flex_none()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
.into_any_element()
}),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
window.insert_hitbox(bounds, HitboxBehavior::Normal)
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
let hitbox = hitbox.clone();
window.with_element_state(id.unwrap(), |state: Option<SharedHandleState>, window| {
let state = state.unwrap_or_default();
window.on_mouse_event({
let state = state.clone();
let hitbox = hitbox.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if phase.bubble()
&& hitbox.is_hovered_at(ev.position, window)
&& state.set(ResizeHandleState::Pressed)
{
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
let hitbox = hitbox.clone();
move |ev: &MouseMoveEvent, phase, window, _| {
if !phase.bubble() {
return;
}
let next = match state.get() {
engaged if engaged.is_active() => ResizeHandleState::Dragging,
_ if hitbox.is_hovered_at(ev.position, window) => {
ResizeHandleState::Hovered
}
_ => ResizeHandleState::Idle,
};
if state.set(next) {
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
let hitbox = hitbox.clone();
move |ev: &MouseUpEvent, _, window, _| {
if !state.get().is_active() {
return;
}
let next = if hitbox.is_hovered_at(ev.position, window) {
ResizeHandleState::Hovered
} else {
ResizeHandleState::Idle
};
if state.set(next) {
window.refresh();
}
}
});
((), state)
});
}
}
pub(crate) fn handle_color(theme: &crate::Theme, active: bool) -> gpui::Hsla {
if active {
theme
.resizable
.active_handle
.unwrap_or(theme.tokens.colors.ring)
} else {
theme.resizable.handle.unwrap_or(theme.tokens.colors.border)
}
}
#[cfg(test)]
mod tests {
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use gpui::{
AnyElement, App, AppContext as _, Axis, Bounds, Context, DragMoveEvent, Empty,
InteractiveElement as _, IntoElement, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent,
ParentElement as _, Pixels, Render, StatefulInteractiveElement as _, Styled as _,
TestAppContext, VisualTestContext, Window, deferred, div, hsla, point,
prelude::FluentBuilder as _, px,
};
use super::{
HandleEdge, ResizeHandleContext, ResizeHandleState, SharedHandleState, handle_color,
resize_handle,
};
use crate::{ElementExt as _, InteractiveElementExt as _, ResizableTheme, Theme};
#[derive(Default)]
struct Probe {
edge: Cell<Option<Option<HandleEdge>>>,
line: Cell<Option<Bounds<Pixels>>>,
mask: Cell<Option<Bounds<Pixels>>>,
prepaints: Cell<usize>,
line_prepainted: Cell<Option<usize>>,
popover_prepainted: Cell<Option<usize>>,
}
impl Probe {
fn tick(&self) -> usize {
let n = self.prepaints.get();
self.prepaints.set(n + 1);
n
}
}
fn hairline(axis: Axis, probe: Rc<Probe>) -> AnyElement {
div()
.flex_none()
.map(|line| match axis {
Axis::Horizontal => line.w(px(1.)).h_full(),
Axis::Vertical => line.h(px(1.)).w_full(),
})
.on_prepaint(move |bounds, window, _| {
probe.line.set(Some(bounds));
probe.mask.set(Some(window.content_mask().bounds));
probe.line_prepainted.set(Some(probe.tick()));
})
.into_any_element()
}
struct NoDrag;
impl Render for NoDrag {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
struct HuggingHarness {
axis: Axis,
edge: HandleEdge,
probe: Rc<Probe>,
}
impl Render for HuggingHarness {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let axis = self.axis;
let probe = self.probe.clone();
let neighbour = || match axis {
Axis::Horizontal => div().w(px(100.)).h_full(),
Axis::Vertical => div().h(px(100.)).w_full(),
};
let popover = div()
.absolute()
.map(|popover| match axis {
Axis::Horizontal => popover.top_0().left(px(50.)).w(px(300.)).h_full(),
Axis::Vertical => popover.left_0().top(px(50.)).h(px(300.)).w_full(),
})
.on_prepaint({
let probe = probe.clone();
move |_, _, _| probe.popover_prepainted.set(Some(probe.tick()))
});
div()
.flex()
.map(|row| match axis {
Axis::Horizontal => row.flex_row().w(px(400.)).h(px(100.)),
Axis::Vertical => row.flex_col().h(px(400.)).w(px(100.)),
})
.child(neighbour().child(deferred(popover)))
.child(
div()
.relative()
.overflow_hidden()
.map(|dock| match axis {
Axis::Horizontal => dock.w(px(200.)).h_full(),
Axis::Vertical => dock.h(px(200.)).w_full(),
})
.child(
resize_handle::<(), NoDrag>("hugging", axis)
.inside(self.edge)
.with_appearance(Rc::new(
move |handle: &ResizeHandleContext,
_: &mut Window,
_: &mut App| {
probe.edge.set(Some(handle.edge()));
Some(hairline(handle.axis(), probe.clone()))
},
)),
),
)
.child(neighbour())
}
}
fn draw_hugging(cx: &mut TestAppContext, axis: Axis, edge: HandleEdge) -> Rc<Probe> {
let probe = Rc::new(Probe::default());
let (_, cx) = cx.add_window_view({
let probe = probe.clone();
move |_, _| HuggingHarness { axis, edge, probe }
});
cx.update(|window, cx| window.draw(cx).clear(cx));
probe
}
fn seam(edge: HandleEdge) -> Pixels {
match edge {
HandleEdge::Leading => px(100.),
HandleEdge::Trailing => px(300.),
}
}
fn along(axis: Axis, bounds: Bounds<Pixels>) -> (Pixels, Pixels) {
match axis {
Axis::Horizontal => (bounds.left(), bounds.right()),
Axis::Vertical => (bounds.top(), bounds.bottom()),
}
}
#[gpui::test]
fn a_hugging_handle_draws_its_line_on_the_seam(cx: &mut TestAppContext) {
for axis in [Axis::Horizontal, Axis::Vertical] {
for edge in [HandleEdge::Leading, HandleEdge::Trailing] {
let probe = draw_hugging(cx, axis, edge);
let line = probe.line.get().expect("the renderer was asked to draw");
let (start, end) = along(axis, line);
let expected = match edge {
HandleEdge::Leading => (seam(edge), seam(edge) + px(1.)),
HandleEdge::Trailing => (seam(edge) - px(1.), seam(edge)),
};
assert_eq!(
(start, end),
expected,
"{axis:?} {edge:?}: the hairline has to be the pixel against the seam"
);
}
}
}
#[gpui::test]
fn a_hugging_handle_paints_beneath_a_popover_deferred_before_it(cx: &mut TestAppContext) {
for axis in [Axis::Horizontal, Axis::Vertical] {
for edge in [HandleEdge::Leading, HandleEdge::Trailing] {
let probe = draw_hugging(cx, axis, edge);
let line = probe
.line_prepainted
.get()
.expect("the line was prepainted");
let popover = probe
.popover_prepainted
.get()
.expect("the popover was prepainted");
assert!(
line < popover,
"{axis:?} {edge:?}: the line (prepaint #{line}) has to go down before \
the popover (#{popover}), or it is painted over it"
);
let mask = probe.mask.get().expect("the line was prepainted");
assert_eq!(
along(axis, mask),
(px(100.), px(300.)),
"{axis:?} {edge:?}: the line is painted under its container's clip"
);
}
}
}
#[gpui::test]
fn a_renderer_is_told_the_edge_a_handle_hugs(cx: &mut TestAppContext) {
for axis in [Axis::Horizontal, Axis::Vertical] {
for edge in [HandleEdge::Leading, HandleEdge::Trailing] {
let probe = draw_hugging(cx, axis, edge);
assert_eq!(probe.edge.get(), Some(Some(edge)), "{axis:?} {edge:?}");
}
}
}
#[test]
fn a_listener_writes_its_progress_back_into_the_stored_state() {
let stored = SharedHandleState::default();
let listener = stored.clone();
assert!(listener.set(ResizeHandleState::Pressed));
assert_eq!(stored.get(), ResizeHandleState::Pressed);
assert!(stored.get().is_active());
}
#[test]
fn setting_the_state_it_already_has_asks_for_no_repaint() {
let state = SharedHandleState::default();
assert!(state.set(ResizeHandleState::Hovered));
assert!(!state.set(ResizeHandleState::Hovered));
}
#[test]
fn only_a_held_handle_is_active() {
assert!(!ResizeHandleState::Idle.is_active());
assert!(!ResizeHandleState::Hovered.is_active());
assert!(ResizeHandleState::Pressed.is_active());
assert!(ResizeHandleState::Dragging.is_active());
}
#[gpui::test]
fn an_unprojected_handle_resolves_from_the_theme_tokens(cx: &mut TestAppContext) {
cx.update(|cx| {
let border = hsla(0., 0., 0.5, 1.0);
let ring = hsla(0.6, 0.5, 0.5, 1.0);
let theme = Theme::global_mut(cx);
theme.tokens.colors.border = border;
theme.tokens.colors.ring = ring;
theme.resizable = ResizableTheme::default();
let theme = Theme::global(cx);
assert_eq!(handle_color(&theme, false), border);
assert_eq!(handle_color(&theme, true), ring);
assert_ne!(handle_color(&theme, false), gpui::Hsla::default());
});
}
#[gpui::test]
fn a_projected_handle_still_wins(cx: &mut TestAppContext) {
cx.update(|cx| {
let projected = hsla(0.3, 0.4, 0.5, 1.0);
let active = hsla(0.9, 0.4, 0.5, 1.0);
let theme = Theme::global_mut(cx);
theme.tokens.colors.border = hsla(0., 0., 0.5, 1.0);
theme.resizable = ResizableTheme {
handle: Some(projected),
active_handle: Some(active),
};
let theme = Theme::global(cx);
assert_eq!(handle_color(&theme, false), projected);
assert_eq!(handle_color(&theme, true), active);
});
}
#[derive(Clone)]
struct Resize;
impl Render for Resize {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
#[derive(Default)]
struct Heard {
container_hovered: Cell<Option<bool>>,
handle_hovered: Cell<Option<bool>>,
double_clicks: Cell<usize>,
drag_moves: RefCell<Vec<Pixels>>,
states: RefCell<Vec<ResizeHandleState>>,
}
struct CallerHarness {
heard: Rc<Heard>,
}
impl Render for CallerHarness {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.heard.clone();
div().w(px(400.)).h(px(100.)).child(
div()
.id("container")
.relative()
.w(px(200.))
.h_full()
.on_hover({
let heard = heard.clone();
move |hovered: &bool, _, _| heard.container_hovered.set(Some(*hovered))
})
.on_drag_move({
let heard = heard.clone();
move |event: &DragMoveEvent<Resize>, _, _| {
heard.drag_moves.borrow_mut().push(event.event.position.x)
}
})
.child(
resize_handle("edge", Axis::Horizontal)
.inside(HandleEdge::Trailing)
.with_appearance(Rc::new({
let heard = heard.clone();
move |handle: &ResizeHandleContext, _: &mut Window, _: &mut App| {
let mut states = heard.states.borrow_mut();
if states.last() != Some(&handle.state()) {
states.push(handle.state());
}
None
}
}))
.on_hover({
let heard = heard.clone();
move |hovered: &bool, _, _| heard.handle_hovered.set(Some(*hovered))
})
.on_double_click({
let heard = heard.clone();
move |_, _, _| {
heard.double_clicks.set(heard.double_clicks.get() + 1)
}
})
.on_drag(Resize, |drag, _, _, cx| cx.new(|_| drag.clone())),
),
)
}
}
#[gpui::test]
fn a_callers_listeners_reach_the_band(cx: &mut TestAppContext) {
let heard = Rc::new(Heard::default());
let (_, cx) = cx.add_window_view({
let heard = heard.clone();
move |_, _| CallerHarness { heard }
});
let draw = |cx: &mut VisualTestContext| cx.update(|window, cx| window.draw(cx).clear(cx));
let press = |cx: &mut VisualTestContext, click_count| {
cx.simulate_event(MouseDownEvent {
position: point(px(198.), px(50.)),
modifiers: Modifiers::default(),
button: MouseButton::Left,
click_count,
first_mouse: false,
});
};
let release = |cx: &mut VisualTestContext, x, click_count| {
cx.simulate_event(MouseUpEvent {
position: point(x, px(50.)),
modifiers: Modifiers::default(),
button: MouseButton::Left,
click_count,
});
};
draw(cx);
cx.simulate_mouse_move(point(px(100.), px(50.)), None, Modifiers::default());
draw(cx);
assert_eq!(heard.container_hovered.get(), Some(true));
cx.simulate_mouse_move(point(px(198.), px(50.)), None, Modifiers::default());
draw(cx);
assert_eq!(heard.handle_hovered.get(), Some(true));
assert_eq!(heard.container_hovered.get(), Some(false));
for click_count in [1, 2] {
press(cx, click_count);
draw(cx);
release(cx, px(198.), click_count);
draw(cx);
}
assert_eq!(heard.double_clicks.get(), 1);
press(cx, 1);
draw(cx);
for x in [px(190.), px(120.)] {
cx.simulate_mouse_move(
point(x, px(50.)),
Some(MouseButton::Left),
Modifiers::default(),
);
draw(cx);
}
release(cx, px(120.), 1);
draw(cx);
assert_eq!(heard.drag_moves.borrow().last(), Some(&px(120.)));
assert_eq!(heard.double_clicks.get(), 1);
assert_eq!(
*heard.states.borrow(),
vec![
ResizeHandleState::Idle,
ResizeHandleState::Hovered,
ResizeHandleState::Pressed,
ResizeHandleState::Hovered,
ResizeHandleState::Pressed,
ResizeHandleState::Hovered,
ResizeHandleState::Pressed,
ResizeHandleState::Dragging,
ResizeHandleState::Idle,
]
);
}
}