use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, Hitbox, HitboxBehavior,
InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window,
div, prelude::FluentBuilder as _, px,
};
use crate::{AxisExt as _, theme::ActiveTheme as _};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
#[doc(hidden)]
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,
}
#[doc(hidden)]
pub struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
edge: Option<HandleEdge>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
appearance: Option<ResizeHandleRenderer>,
}
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(),
on_drag: None,
drag_value: None,
edge: None,
appearance: None,
axis,
}
}
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(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
pub fn inside(mut self, edge: HandleEdge) -> Self {
self.edge = Some(edge);
self
}
}
#[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.;
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 = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&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, rc::Rc};
use gpui::{
AnyElement, App, Axis, Bounds, Context, Empty, IntoElement, ParentElement as _, Pixels,
Render, Styled as _, TestAppContext, Window, deferred, div, hsla,
prelude::FluentBuilder as _, px,
};
use super::{
HandleEdge, ResizeHandleContext, ResizeHandleState, SharedHandleState, handle_color,
resize_handle,
};
use crate::{ElementExt 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);
});
}
}