use std::rc::Rc;
use gpui::{
canvas, div, prelude::*, px, AnyElement, App, Bounds, Context, CursorStyle, DispatchPhase, Div,
ElementId, Entity, FocusHandle, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Rems, Role, SharedString, Window,
};
use crate::a11y::{A11y, Announce};
use crate::element_id::scoped;
use crate::elements::separator::Separator;
use crate::layout::{h_stack, v_stack};
use crate::theme::{ActiveTheme, ControlMetrics, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use crate::traits::orientable::{Orientable, Orientation};
type ResizeHandler = Rc<dyn Fn(&f32, &mut Window, &mut App) + 'static>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct SplitterMetrics {
pub(crate) band: Rems,
pub(crate) highlight: Rems,
pub(crate) arrow_step: Rems,
pub(crate) default_floor: Rems,
}
impl SplitterMetrics {
pub(crate) fn for_rung(control: ControlMetrics) -> Self {
Self {
band: control.gap * 2.0,
highlight: control.gap,
arrow_step: control.height,
default_floor: control.height * 3.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct SplitterGeometry {
length: Pixels,
band: Pixels,
min_start: Pixels,
min_end: Pixels,
}
impl SplitterGeometry {
pub(crate) fn new(length: Pixels, band: Pixels, min_start: Pixels, min_end: Pixels) -> Self {
Self {
length: length.max(px(0.)),
band: band.max(px(0.)),
min_start: min_start.max(px(0.)),
min_end: min_end.max(px(0.)),
}
}
pub(crate) fn usable(&self) -> Pixels {
(self.length - self.band).max(px(0.))
}
pub(crate) fn range(&self) -> (f32, f32) {
let usable = f32::from(self.usable());
let (low, high) = if usable > 0. {
(
(f32::from(self.min_start) / usable).clamp(0., 1.),
(1. - f32::from(self.min_end) / usable).clamp(0., 1.),
)
} else {
(1., 0.)
};
if low <= high {
(low, high)
} else {
let point = self.proportional();
(point, point)
}
}
fn proportional(&self) -> f32 {
let total = f32::from(self.min_start) + f32::from(self.min_end);
if total > 0. {
f32::from(self.min_start) / total
} else {
0.5
}
}
pub(crate) fn clamp(&self, ratio: f32) -> f32 {
let (low, high) = self.range();
if ratio.is_nan() {
return low;
}
ratio.clamp(low, high)
}
pub(crate) fn ratio_at(&self, offset: Pixels) -> f32 {
let usable = f32::from(self.usable());
if usable <= 0. {
return self.clamp(self.proportional());
}
self.clamp(f32::from(offset) / usable)
}
pub(crate) fn step_ratio(&self, step: Pixels) -> f32 {
let usable = f32::from(self.usable());
if usable <= 0. {
return 0.;
}
f32::from(step) / usable
}
}
struct SplitterState {
container: Option<Bounds<Pixels>>,
grab: Option<Pixels>,
focus_handle: FocusHandle,
}
impl SplitterState {
fn new(cx: &mut Context<Self>) -> Self {
Self {
container: None,
grab: None,
focus_handle: cx.focus_handle(),
}
}
}
#[derive(IntoElement)]
pub struct Splitter {
id: ElementId,
name: SharedString,
ratio: f32,
orientation: Orientation,
size: ControlSize,
start: Option<AnyElement>,
end: Option<AnyElement>,
min_start: Option<Pixels>,
min_end: Option<Pixels>,
on_resize: Option<ResizeHandler>,
}
pub fn splitter(id: impl Into<ElementId>, name: impl Into<SharedString>, ratio: f32) -> Splitter {
Splitter::new(id, name, ratio)
}
impl Splitter {
pub fn new(id: impl Into<ElementId>, name: impl Into<SharedString>, ratio: f32) -> Self {
Self {
id: id.into(),
name: name.into(),
ratio,
orientation: Orientation::Vertical,
size: ControlSize::default(),
start: None,
end: None,
min_start: None,
min_end: None,
on_resize: None,
}
}
pub fn start(mut self, pane: impl IntoElement) -> Self {
self.start = Some(pane.into_any_element());
self
}
pub fn end(mut self, pane: impl IntoElement) -> Self {
self.end = Some(pane.into_any_element());
self
}
pub fn min_start(mut self, min: impl Into<Pixels>) -> Self {
self.min_start = Some(min.into());
self
}
pub fn min_end(mut self, min: impl Into<Pixels>) -> Self {
self.min_end = Some(min.into());
self
}
pub fn on_resize(mut self, handler: impl Fn(&f32, &mut Window, &mut App) + 'static) -> Self {
self.on_resize = Some(Rc::new(handler));
self
}
fn is_vertical(&self) -> bool {
matches!(self.orientation, Orientation::Vertical)
}
fn announcement(
&self,
geometry: Option<SplitterGeometry>,
step: Pixels,
focus: Option<FocusHandle>,
) -> A11y {
let (position, low, high, step) = match geometry {
Some(geometry) => {
let (low, high) = geometry.range();
(
geometry.clamp(self.ratio),
low,
high,
geometry.step_ratio(step),
)
}
None => (self.ratio.clamp(0., 1.), 0., 1., 0.01),
};
let a11y = A11y::new(Role::Splitter)
.name(self.name.clone())
.orientation(match self.orientation {
Orientation::Vertical => gpui::Orientation::Vertical,
Orientation::Horizontal => gpui::Orientation::Horizontal,
})
.number_value(
f64::from(position) * 100.,
f64::from(low) * 100.,
f64::from(high) * 100.,
f64::from(step) * 100.,
);
match focus {
Some(handle) => a11y.focus_handle(handle),
None => a11y.focusable(),
}
}
}
impl Accessible for Splitter {
fn a11y(&self) -> A11y {
self.announcement(None, px(0.), None)
}
}
impl Orientable for Splitter {
fn orientation(mut self, orientation: Orientation) -> Self {
self.orientation = orientation;
self
}
}
impl ControlSized for Splitter {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
fn ratio_for_key(
key: &str,
orientation: Orientation,
current: f32,
geometry: SplitterGeometry,
step: Pixels,
) -> Option<f32> {
let (back, forward) = match orientation {
Orientation::Vertical => ("left", "right"),
Orientation::Horizontal => ("up", "down"),
};
let (low, high) = geometry.range();
let step = geometry.step_ratio(step);
let next = if key == back {
geometry.clamp(current - step)
} else if key == forward {
geometry.clamp(current + step)
} else if key == "home" {
low
} else if key == "end" {
high
} else {
return None;
};
Some(next)
}
fn emit(
handler: &Option<ResizeHandler>,
next: f32,
current: f32,
window: &mut Window,
cx: &mut App,
) {
if (next - current).abs() <= f32::EPSILON {
return;
}
if let Some(handler) = handler {
handler(&next, window, cx);
}
}
fn pane(child: Option<AnyElement>, grow: f32) -> Div {
div()
.flex_grow(grow)
.flex_basis(px(0.))
.min_w(px(0.))
.min_h(px(0.))
.overflow_hidden()
.children(child)
}
impl RenderOnce for Splitter {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state: Entity<SplitterState> =
window.use_keyed_state(scoped(&self.id, "state"), cx, |_window, cx| {
SplitterState::new(cx)
});
let rem_size = window.rem_size();
let control = cx.theme().control(self.size);
let metrics = SplitterMetrics::for_rung(control);
let band_px = metrics.band.to_pixels(rem_size);
let step_px = metrics.arrow_step.to_pixels(rem_size);
let floor = metrics.default_floor.to_pixels(rem_size);
let vertical = self.is_vertical();
let (container_bounds, dragging, focus_handle) = {
let state = state.read(cx);
(
state.container,
state.grab.is_some(),
state.focus_handle.clone(),
)
};
let geometry = container_bounds.map(|bounds| {
SplitterGeometry::new(
if vertical {
bounds.size.width
} else {
bounds.size.height
},
band_px,
self.min_start.unwrap_or(floor),
self.min_end.unwrap_or(floor),
)
});
let drawn = match geometry {
Some(geometry) => geometry.clamp(self.ratio),
None => self.ratio.clamp(0., 1.),
};
let boundary = geometry.map(|geometry| geometry.usable() * drawn);
let a11y = self.announcement(geometry, step_px, Some(focus_handle.clone()));
let theme = cx.theme();
let (line_color, hover_color) = (theme.border_subtle(), theme.accent());
let Splitter {
id,
orientation,
start,
end,
on_resize,
..
} = self;
let band = div()
.id(scoped(&id, "band"))
.announce(a11y)
.flex_none()
.flex()
.items_center()
.justify_center()
.map(|band| {
if vertical {
band.w(metrics.band)
.h_full()
.cursor(CursorStyle::ResizeLeftRight)
} else {
band.h(metrics.band)
.w_full()
.cursor(CursorStyle::ResizeUpDown)
}
})
.child(if dragging {
div()
.bg(hover_color)
.map(|line| {
if vertical {
line.w(metrics.highlight).h_full()
} else {
line.h(metrics.highlight).w_full()
}
})
.into_any_element()
} else {
Separator::new().orientation(orientation).into_any_element()
})
.hover(|band| band.bg(line_color))
.on_mouse_down(MouseButton::Left, {
let state = state.clone();
let focus_handle = focus_handle.clone();
move |event: &MouseDownEvent, window, cx| {
let (Some(bounds), Some(boundary)) = (container_bounds, boundary) else {
return;
};
let along = if vertical {
bounds.origin.x
} else {
bounds.origin.y
};
let pointer = if vertical {
event.position.x
} else {
event.position.y
};
state.update(cx, |state, cx| {
state.grab = Some(along + boundary - pointer);
cx.notify();
});
focus_handle.focus(window, cx);
cx.stop_propagation();
}
})
.on_key_down({
let on_resize = on_resize.clone();
move |event: &KeyDownEvent, window, cx| {
let Some(geometry) = geometry else {
return;
};
let Some(next) = ratio_for_key(
event.keystroke.key.as_str(),
orientation,
drawn,
geometry,
step_px,
) else {
return;
};
cx.stop_propagation();
emit(&on_resize, next, drawn, window, cx);
}
});
let measure = canvas(move |bounds, _window, _cx| bounds, {
let state = state.clone();
move |bounds, _, window, cx| {
if state.read(cx).container != Some(bounds) {
state.update(cx, |state, cx| {
state.container = Some(bounds);
cx.notify();
});
}
let Some(geometry) = geometry else {
return;
};
let origin = if vertical {
bounds.origin.x
} else {
bounds.origin.y
};
window.on_mouse_event({
let state = state.clone();
let on_resize = on_resize.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
let Some(grab) = state.read(cx).grab else {
return;
};
if !event.dragging() {
state.update(cx, |state, cx| {
state.grab = None;
cx.notify();
});
return;
}
let pointer = if vertical {
event.position.x
} else {
event.position.y
};
let next = geometry.ratio_at(pointer + grab - origin);
emit(&on_resize, next, drawn, window, cx);
}
});
window.on_mouse_event({
let state = state.clone();
move |event: &MouseUpEvent, phase, _window, cx| {
if phase != DispatchPhase::Bubble
|| event.button != MouseButton::Left
|| state.read(cx).grab.is_none()
{
return;
}
state.update(cx, |state, cx| {
state.grab = None;
cx.notify();
});
}
});
}
})
.absolute()
.size_full();
let base = if vertical { h_stack() } else { v_stack() };
base.relative()
.size_full()
.child(pane(start, drawn))
.child(band)
.child(pane(end, 1. - drawn))
.child(measure)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::{role_requires_a_name, role_requires_keyboard_focus, FocusNavigation};
use crate::theme::ControlScale;
use gpui::{size, Modifiers, Point, Render, Size, TestAppContext, VisualTestContext};
use std::cell::RefCell;
fn geometry(length: f32, band: f32, min_start: f32, min_end: f32) -> SplitterGeometry {
SplitterGeometry::new(px(length), px(band), px(min_start), px(min_end))
}
#[test]
fn the_band_is_taken_out_before_the_ratio_divides_anything() {
let split = geometry(408., 8., 0., 0.);
assert_eq!(split.usable(), px(400.));
assert_eq!(split.usable() * split.clamp(0.5), px(200.));
}
#[test]
fn the_floors_bound_the_range_at_both_ends() {
let split = geometry(408., 8., 100., 50.);
let (low, high) = split.range();
assert_eq!(low, 0.25, "a 100px floor in 400px of usable space");
assert_eq!(high, 0.875, "a 50px floor in 400px of usable space");
}
#[test]
fn clamping_holds_a_ratio_inside_the_floors() {
let split = geometry(408., 8., 100., 50.);
assert_eq!(split.clamp(0.5), 0.5);
assert_eq!(split.clamp(0.0), 0.25);
assert_eq!(split.clamp(1.0), 0.875);
assert_eq!(split.clamp(-4.0), 0.25);
assert_eq!(split.clamp(f32::NAN), 0.25);
}
#[test]
fn a_position_reads_back_as_the_ratio_it_means() {
let split = geometry(408., 8., 0., 0.);
assert_eq!(split.ratio_at(px(100.)), 0.25);
assert_eq!(split.ratio_at(px(300.)), 0.75);
}
#[test]
fn a_drag_past_either_end_stops_at_the_floor() {
let split = geometry(408., 8., 100., 50.);
assert_eq!(split.ratio_at(px(-500.)), 0.25);
assert_eq!(split.ratio_at(px(5_000.)), 0.875);
}
#[test]
fn unsatisfiable_floors_collapse_to_a_proportional_split() {
let split = geometry(108., 8., 200., 100.);
let (low, high) = split.range();
assert_eq!(low, high, "the range has to collapse to a single ratio");
assert!(
(low - 2. / 3.).abs() < 1e-6,
"a 200/100 pair of floors splits two thirds to the start pane, not {low}"
);
assert_eq!(split.clamp(0.1), low);
assert_eq!(split.clamp(0.9), low);
}
#[test]
fn a_container_smaller_than_the_band_does_not_panic() {
for split in [geometry(0., 8., 40., 40.), geometry(4., 8., 40., 10.)] {
assert_eq!(split.usable(), px(0.));
let (low, high) = split.range();
assert_eq!(low, high);
assert!(split.clamp(0.5).is_finite());
assert!(split.ratio_at(px(20.)).is_finite());
assert_eq!(split.step_ratio(px(20.)), 0.);
}
}
#[test]
fn with_no_floors_the_whole_range_is_available() {
let split = geometry(408., 8., 0., 0.);
assert_eq!(split.range(), (0., 1.));
assert_eq!(split.clamp(0.), 0.);
assert_eq!(split.clamp(1.), 1.);
}
#[test]
fn one_arrow_press_is_a_fraction_of_the_usable_space() {
let split = geometry(408., 8., 0., 0.);
assert_eq!(split.step_ratio(px(20.)), 0.05);
}
#[test]
fn negative_inputs_are_treated_as_zero() {
let split = SplitterGeometry::new(px(-10.), px(-2.), px(-30.), px(-30.));
assert_eq!(split.usable(), px(0.));
assert_eq!(
split.clamp(0.5),
0.5,
"no floors left to be proportional to"
);
}
#[test]
fn every_dimension_comes_off_the_rung() {
for size in ControlSize::ALL {
let control = ControlScale::default().metrics(size);
let metrics = SplitterMetrics::for_rung(control);
assert_eq!(metrics.band.0, control.gap.0 * 2.0, "{}", size.name());
assert_eq!(metrics.highlight.0, control.gap.0, "{}", size.name());
assert_eq!(metrics.arrow_step.0, control.height.0, "{}", size.name());
assert_eq!(
metrics.default_floor.0,
control.height.0 * 3.0,
"{}",
size.name()
);
}
}
#[test]
fn the_band_is_much_wider_than_the_hairline_it_draws() {
let bands: Vec<f32> = ControlSize::ALL
.into_iter()
.map(|size| {
SplitterMetrics::for_rung(ControlScale::default().metrics(size))
.band
.0
* 16.
})
.collect();
assert_eq!(bands, vec![6., 8., 12.]);
}
#[test]
fn the_metrics_grow_with_the_rung() {
let metrics: Vec<SplitterMetrics> = ControlSize::ALL
.into_iter()
.map(|size| SplitterMetrics::for_rung(ControlScale::default().metrics(size)))
.collect();
for pair in metrics.windows(2) {
assert!(pair[0].band.0 < pair[1].band.0);
assert!(pair[0].arrow_step.0 < pair[1].arrow_step.0);
assert!(pair[0].default_floor.0 < pair[1].default_floor.0);
}
}
#[test]
fn the_default_floor_is_bigger_than_the_band() {
for size in ControlSize::ALL {
let metrics = SplitterMetrics::for_rung(ControlScale::default().metrics(size));
assert!(
metrics.default_floor.0 > metrics.band.0,
"{}: a floor no bigger than the divider is not a floor",
size.name(),
);
}
}
fn keyed(key: &str, orientation: Orientation, current: f32) -> Option<f32> {
ratio_for_key(
key,
orientation,
current,
geometry(408., 8., 40., 40.),
px(20.),
)
}
#[test]
fn the_arrows_on_the_split_axis_move_the_divider_one_step() {
assert_eq!(keyed("right", Orientation::Vertical, 0.5), Some(0.55));
assert_eq!(keyed("left", Orientation::Vertical, 0.5), Some(0.45));
assert_eq!(keyed("down", Orientation::Horizontal, 0.5), Some(0.55));
assert_eq!(keyed("up", Orientation::Horizontal, 0.5), Some(0.45));
}
#[test]
fn the_cross_axis_arrows_are_not_this_elements() {
assert_eq!(keyed("up", Orientation::Vertical, 0.5), None);
assert_eq!(keyed("down", Orientation::Vertical, 0.5), None);
assert_eq!(keyed("left", Orientation::Horizontal, 0.5), None);
assert_eq!(keyed("right", Orientation::Horizontal, 0.5), None);
assert_eq!(keyed("enter", Orientation::Vertical, 0.5), None);
assert_eq!(keyed("escape", Orientation::Vertical, 0.5), None);
}
#[test]
fn home_and_end_go_to_the_two_floors() {
assert_eq!(keyed("home", Orientation::Vertical, 0.5), Some(0.1));
assert_eq!(keyed("end", Orientation::Vertical, 0.5), Some(0.9));
}
#[test]
fn the_arrows_stop_at_the_floors_too() {
assert_eq!(keyed("left", Orientation::Vertical, 0.1), Some(0.1));
assert_eq!(keyed("right", Orientation::Vertical, 0.9), Some(0.9));
}
fn node_for(a11y: A11y) -> gpui::accesskit::Node {
crate::a11y::test_support::announced_element(div().id("band").announce(a11y))
.node
.expect("a splitter with an id and a role is a node")
}
fn close(actual: Option<f64>, expected: f64, what: &str) {
let actual = actual.unwrap_or_else(|| panic!("the node reports no {what}"));
assert!(
(actual - expected).abs() < 1e-4,
"{what} was {actual}, expected about {expected}"
);
}
#[test]
fn a_splitter_announces_a_named_splitter_with_its_position() {
let split = splitter("panes", "Editor and preview", 0.6);
let node = node_for(split.announcement(Some(geometry(408., 8., 40., 40.)), px(20.), None));
assert_eq!(node.label(), Some("Editor and preview"));
close(node.numeric_value(), 60., "position");
close(node.min_numeric_value(), 10., "minimum");
close(node.max_numeric_value(), 90., "maximum");
close(node.numeric_value_step(), 5., "step");
}
#[test]
fn an_unmeasured_splitter_still_reports_a_range() {
let split = splitter("panes", "Panes", 0.4);
assert_eq!(split.a11y().role(), Role::Splitter);
let node = node_for(split.a11y());
close(node.numeric_value(), 40., "position");
close(node.min_numeric_value(), 0., "minimum");
close(node.max_numeric_value(), 100., "maximum");
close(node.numeric_value_step(), 1., "step");
}
#[test]
fn the_announced_position_is_clamped_like_the_drawn_one() {
let split = splitter("panes", "Panes", 5.0);
let node = node_for(split.announcement(Some(geometry(408., 8., 40., 40.)), px(20.), None));
close(node.numeric_value(), 90., "position");
}
#[test]
fn the_divider_announces_which_way_it_runs() {
assert_eq!(
node_for(splitter("panes", "Panes", 0.5).a11y()).orientation(),
Some(gpui::Orientation::Vertical),
);
assert_eq!(
node_for(splitter("panes", "Panes", 0.5).horizontal().a11y()).orientation(),
Some(gpui::Orientation::Horizontal),
);
}
#[test]
fn a_splitter_is_one_of_the_roles_that_must_be_named() {
assert!(role_requires_a_name(Role::Splitter));
assert!(A11y::new(Role::Splitter).is_missing_a_required_name());
assert!(!splitter("panes", "Panes", 0.5)
.a11y()
.is_missing_a_required_name());
}
#[test]
fn a_splitter_declares_that_it_takes_keyboard_focus() {
assert!(
role_requires_keyboard_focus(Role::Splitter),
"a splitter owns one tab stop and moves its value with the arrow keys, so \
announcing it without taking focus would reach a screen reader and not a \
keyboard"
);
let declared = splitter("panes", "Panes", 0.5).a11y();
assert!(
declared.is_focusable(),
"the splitter declines the focus its role requires"
);
assert!(
!declared.is_missing_a_focus_decision(),
"`announce` would panic in a debug build on this announcement"
);
}
type Build = Box<dyn Fn(&mut Window, &mut App) -> AnyElement>;
struct Harness {
build: Build,
drawn: Entity<usize>,
}
impl Render for Harness {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.drawn.update(cx, |count, _| *count += 1);
(self.build)(window, cx)
}
}
fn draw(
cx: &mut TestAppContext,
window_size: Size<Pixels>,
build: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> &mut VisualTestContext {
cx.update(crate::theme::init);
let drawn = cx.update(|cx| cx.new(|_| 0usize));
let counter = drawn.clone();
let window = cx.open_window(window_size, move |_window, _cx| Harness {
build: Box::new(build),
drawn: counter,
});
let cx = VisualTestContext::from_window(*std::ops::Deref::deref(&window), cx).into_mut();
cx.run_until_parked();
assert!(
drawn.read_with(cx, |count, _| *count) > 0,
"the harness never drew, so this test is checking nothing"
);
cx
}
type Emitted = Rc<RefCell<Vec<f32>>>;
fn panes(
ratio: Rc<RefCell<f32>>,
emitted: Emitted,
orientation: Orientation,
) -> impl Fn(&mut Window, &mut App) -> AnyElement + 'static {
move |_window, _cx| {
let ratio_cell = ratio.clone();
let emitted = emitted.clone();
splitter("test-split", "Panes", *ratio.borrow())
.orientation(orientation)
.min_start(px(40.))
.min_end(px(40.))
.start(div().child("start"))
.end(div().child("end"))
.on_resize(move |next: &f32, _window, _cx| {
*ratio_cell.borrow_mut() = *next;
emitted.borrow_mut().push(*next);
})
.into_any_element()
}
}
fn scenario(
cx: &mut TestAppContext,
orientation: Orientation,
) -> (&mut VisualTestContext, Rc<RefCell<f32>>, Emitted) {
let ratio = Rc::new(RefCell::new(0.5));
let emitted: Emitted = Rc::new(RefCell::new(Vec::new()));
let cx = draw(
cx,
size(px(408.), px(408.)),
panes(ratio.clone(), emitted.clone(), orientation),
);
cx.run_until_parked();
(cx, ratio, emitted)
}
#[gpui::test]
fn a_side_by_side_splitter_draws(cx: &mut TestAppContext) {
scenario(cx, Orientation::Vertical);
}
#[gpui::test]
fn a_stacked_splitter_draws(cx: &mut TestAppContext) {
scenario(cx, Orientation::Horizontal);
}
#[gpui::test]
fn tab_reaches_the_band(cx: &mut TestAppContext) {
cx.update(crate::init);
let ratio = Rc::new(RefCell::new(0.5));
let emitted: Emitted = Rc::new(RefCell::new(Vec::new()));
let build = panes(ratio.clone(), emitted.clone(), Orientation::Vertical);
let slot: Rc<RefCell<Option<FocusHandle>>> = Rc::new(RefCell::new(None));
let for_render = slot.clone();
let drawn = cx.update(|cx| cx.new(|_| 0usize));
let counter = drawn.clone();
let window = cx.open_window(size(px(408.), px(408.)), move |_window, _cx| Harness {
build: Box::new(move |window, app| {
let root = for_render
.borrow_mut()
.get_or_insert_with(|| app.focus_handle())
.clone();
div()
.id("splitter-harness-root")
.track_focus(&root)
.moves_focus_on_tab()
.size_full()
.child(build(window, app))
.into_any_element()
}),
drawn: counter,
});
let cx = VisualTestContext::from_window(*std::ops::Deref::deref(&window), cx).into_mut();
cx.run_until_parked();
assert!(
drawn.read_with(cx, |count, _| *count) > 0,
"the harness never drew, so this test is checking nothing"
);
let root = slot.borrow().clone().expect("the harness drew");
cx.update(|window, app| window.focus(&root, app));
cx.run_until_parked();
cx.simulate_keystrokes("tab");
cx.run_until_parked();
cx.simulate_keystrokes("right");
cx.run_until_parked();
assert!(
!emitted.borrow().is_empty(),
"Tab never reached the band: the right arrow moved nothing. The band is a tab \
stop only by declaration — `announce` applies `track_focus(&handle.tab_stop(true))` \
— so a `tab_index` or a second plain `track_focus` on the band would undo it."
);
let moved = *ratio.borrow();
assert!(
moved > 0.5,
"the divider moved, but not rightwards: {moved}"
);
}
#[gpui::test]
fn dragging_the_band_moves_the_boundary(cx: &mut TestAppContext) {
let (cx, ratio, emitted) = scenario(cx, Orientation::Vertical);
cx.simulate_mouse_move(Point::new(px(204.), px(50.)), None, Modifiers::none());
cx.run_until_parked();
cx.simulate_mouse_down(
Point::new(px(204.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
cx.simulate_mouse_move(
Point::new(px(304.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
assert!(
!emitted.borrow().is_empty(),
"the drag emitted nothing at all"
);
let moved = *ratio.borrow();
assert!(
(moved - 0.75).abs() < 1e-4,
"a 100px drag across 400px of usable space should land on 0.75, not {moved}"
);
}
#[gpui::test]
fn grabbing_the_band_off_centre_does_not_jump_the_divider(cx: &mut TestAppContext) {
let (cx, ratio, emitted) = scenario(cx, Orientation::Vertical);
cx.simulate_mouse_move(Point::new(px(207.), px(50.)), None, Modifiers::none());
cx.run_until_parked();
cx.simulate_mouse_down(
Point::new(px(207.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
assert!(
emitted.borrow().is_empty(),
"pressing on the band moved the divider before anything was dragged"
);
cx.simulate_mouse_move(
Point::new(px(247.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
let moved = *ratio.borrow();
assert!(
(moved - 0.6).abs() < 1e-4,
"a 40px drag from a grab 7px off the boundary lands on exactly 0.6, not {moved} — \
a tolerance loose enough to swallow those 7px would not see the bug this is for"
);
}
#[gpui::test]
fn a_move_with_no_button_held_ends_the_drag(cx: &mut TestAppContext) {
let (cx, ratio, _emitted) = scenario(cx, Orientation::Vertical);
cx.simulate_mouse_move(Point::new(px(204.), px(50.)), None, Modifiers::none());
cx.run_until_parked();
cx.simulate_mouse_down(
Point::new(px(204.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
cx.simulate_mouse_move(Point::new(px(304.), px(50.)), None, Modifiers::none());
cx.run_until_parked();
let after_release = *ratio.borrow();
cx.simulate_mouse_move(
Point::new(px(360.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
assert_eq!(
*ratio.borrow(),
after_release,
"the drag survived a move with no button held"
);
}
#[gpui::test]
fn a_stacked_splitter_drags_vertically(cx: &mut TestAppContext) {
let (cx, ratio, _emitted) = scenario(cx, Orientation::Horizontal);
cx.simulate_mouse_move(Point::new(px(50.), px(204.)), None, Modifiers::none());
cx.run_until_parked();
cx.simulate_mouse_down(
Point::new(px(50.), px(204.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
cx.simulate_mouse_move(
Point::new(px(50.), px(104.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
let moved = *ratio.borrow();
assert!(
(moved - 0.25).abs() < 1e-4,
"a 100px upward drag across 400px should land on 0.25, not {moved}"
);
}
#[gpui::test]
fn a_drag_past_the_end_stops_at_the_floor(cx: &mut TestAppContext) {
let (cx, ratio, _emitted) = scenario(cx, Orientation::Vertical);
cx.simulate_mouse_move(Point::new(px(204.), px(50.)), None, Modifiers::none());
cx.run_until_parked();
cx.simulate_mouse_down(
Point::new(px(204.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
cx.simulate_mouse_move(
Point::new(px(5_000.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
let moved = *ratio.borrow();
assert!(
(moved - 0.9).abs() < 1e-4,
"a 40px floor in 400px leaves 0.9 as the maximum, not {moved}"
);
}
#[gpui::test]
fn a_splitter_with_no_handler_does_not_move(cx: &mut TestAppContext) {
let cx = draw(cx, size(px(408.), px(408.)), |_window, _cx| {
splitter("fixed-split", "Panes", 0.5)
.start(div().child("start"))
.end(div().child("end"))
.into_any_element()
});
cx.run_until_parked();
cx.simulate_mouse_move(Point::new(px(204.), px(50.)), None, Modifiers::none());
cx.run_until_parked();
cx.simulate_mouse_down(
Point::new(px(204.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.simulate_mouse_move(
Point::new(px(304.), px(50.)),
MouseButton::Left,
Modifiers::none(),
);
cx.run_until_parked();
}
}