Files
Ely-GPUI-Components/src/layout/split.rs
T

233 lines
7.8 KiB
Rust

use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId,
InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, Stateful,
StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use super::seeded::use_seeded;
use crate::theme::ActiveTheme;
/// Thin divider with a wide grab area. `axis` is the split's direction.
pub fn resize_handle(id: impl Into<ElementId>, axis: Axis, cx: &App) -> Stateful<gpui::Div> {
let theme = cx.theme();
let (line, lit, hit) = (theme.colors.border, theme.colors.focus, theme.handle_hit());
let base = div()
.id(id)
.group("resize")
.flex_none()
.flex()
.justify_center()
.items_center();
match axis {
Axis::Horizontal => base
.w(hit)
.h_full()
.cursor(CursorStyle::ResizeLeftRight)
.child(
div()
.h_full()
.border_l_1()
.border_color(line)
.group_hover("resize", |s| s.border_color(lit)),
),
Axis::Vertical => base
.h(hit)
.w_full()
.cursor(CursorStyle::ResizeUpDown)
.child(
div()
.w_full()
.border_t_1()
.border_color(line)
.group_hover("resize", |s| s.border_color(lit)),
),
}
}
struct DividerDrag {
owner: EntityId,
ix: usize,
start: Vec<f32>,
anchor: Rc<Cell<Option<Pixels>>>,
}
/// Moves divider `ix` by `delta` of the whole, keeping each pane at `floor` or more.
fn shift(start: &[f32], ix: usize, delta: f32, floor: f32) -> Vec<f32> {
let pair = start[ix] + start[ix + 1];
let floor = floor.min(pair / 2.0);
let left = (start[ix] + delta).clamp(floor, pair - floor);
let mut next = start.to_vec();
next[ix] = left;
next[ix + 1] = pair - left;
next
}
/// Weights are finite and non-negative, with a finite, positive sum.
pub(crate) fn check_weights(weights: &[f32]) -> anyhow::Result<f32> {
anyhow::ensure!(
weights
.iter()
.all(|weight| weight.is_finite() && *weight >= 0.0),
"split weights {weights:?} must be finite and non-negative"
);
let total: f32 = weights.iter().sum();
anyhow::ensure!(
total.is_finite() && total > 0.0,
"split weights {weights:?} need a finite, positive sum"
);
Ok(total)
}
type OnResize = Rc<dyn Fn(&[f32], &mut Window, &mut App)>;
/// Panes side by side or stacked, split by draggable handles.
#[derive(IntoElement)]
pub struct SplitPane {
id: ElementId,
axis: Axis,
panes: Vec<AnyElement>,
sizes: Option<Vec<f32>>,
min: Pixels,
on_resize: Option<OnResize>,
}
impl SplitPane {
/// `min` is the smallest a pane may shrink to.
pub fn new(id: impl Into<ElementId>, axis: Axis, min: Pixels) -> Self {
Self {
id: id.into(),
axis,
panes: Vec::new(),
sizes: None,
min,
on_resize: None,
}
}
pub fn pane(mut self, pane: impl IntoElement) -> Self {
self.panes.push(pane.into_any_element());
self
}
/// Shares, one per pane, normalized. A new value replaces dragged ones.
pub fn sizes(mut self, sizes: &[f32]) -> Self {
self.sizes = Some(sizes.to_vec());
self
}
/// Reports shares after each drag step.
pub fn on_resize(mut self, handler: impl Fn(&[f32], &mut Window, &mut App) + 'static) -> Self {
self.on_resize = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SplitPane {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.panes.len();
assert!(count >= 2, "split pane {:?} needs two panes", self.id);
let weights = self.sizes.unwrap_or_else(|| vec![1.0; count]);
assert_eq!(
weights.len(),
count,
"split pane {:?}: one size per pane",
self.id
);
let total = check_weights(&weights)
.unwrap_or_else(|error| panic!("split pane {:?}: {error:#}", self.id));
let normalized: Vec<f32> = weights.iter().map(|weight| weight / total).collect();
let fractions = use_seeded(self.id.clone(), normalized, window, cx);
let owner = fractions.entity_id();
let on_resize = self.on_resize;
let shares = fractions.read(cx).value.clone();
let (axis, min) = (self.axis, self.min);
let mut root = div().id(self.id).size_full().flex().on_drag_move(
move |event: &DragMoveEvent<DividerDrag>, window, cx| {
let drag = event.drag(cx);
if drag.owner != owner {
return;
}
let (along, extent) = match axis {
Axis::Horizontal => (event.event.position.x, event.bounds.size.width),
Axis::Vertical => (event.event.position.y, event.bounds.size.height),
};
let anchor = match drag.anchor.get() {
Some(anchor) => anchor,
None => {
drag.anchor.set(Some(along));
along
}
};
let next = shift(
&drag.start,
drag.ix,
(along - anchor) / extent,
min / extent,
);
if let Some(report) = &on_resize {
report(&next, window, cx);
}
fractions.update(cx, |shares, cx| {
shares.value = next;
cx.notify();
});
},
);
root = match axis {
Axis::Horizontal => root.flex_row(),
Axis::Vertical => root.flex_col(),
};
let mut children = Vec::with_capacity(count * 2);
for (ix, pane) in self.panes.into_iter().enumerate() {
if ix > 0 {
let drag = DividerDrag {
owner,
ix: ix - 1,
start: shares.clone(),
anchor: Rc::new(Cell::new(None)),
};
children.push(
resize_handle(("divider", ix), axis, cx)
.on_drag(drag, |_, _, _, cx| cx.new(|_| EmptyView))
.into_any_element(),
);
}
let mut cell = div()
.relative()
.overflow_hidden()
.flex_basis(gpui::relative(0.0));
cell.style().flex_grow = Some(shares[ix]);
children.push(cell.child(pane).into_any_element());
}
root.children(children)
}
}
#[cfg(test)]
mod tests {
use super::{check_weights, shift};
#[test]
fn weights_must_be_finite_non_negative_and_sum_positive() {
assert_eq!(check_weights(&[1.0, 3.0]).unwrap(), 4.0);
assert_eq!(check_weights(&[0.0, 1.0]).unwrap(), 1.0);
assert!(check_weights(&[0.0, 0.0]).is_err());
assert!(check_weights(&[-1.0, 2.0]).is_err());
assert!(check_weights(&[f32::NAN, 1.0]).is_err());
assert!(check_weights(&[f32::MAX, f32::MAX]).is_err());
}
#[test]
fn shift_moves_one_boundary_and_respects_the_floor() {
let moved = shift(&[0.5, 0.5], 0, 0.1, 0.1);
assert!((moved[0] - 0.6).abs() < 1e-6 && (moved[1] - 0.4).abs() < 1e-6);
let floored = shift(&[0.5, 0.5], 0, -0.9, 0.2);
assert!((floored[0] - 0.2).abs() < 1e-6);
let three = shift(&[0.2, 0.3, 0.5], 1, 0.1, 0.05);
assert!((three[1] - 0.4).abs() < 1e-6 && (three[2] - 0.4).abs() < 1e-6);
assert!((three[0] - 0.2).abs() < 1e-6);
}
}