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, axis: Axis, cx: &App) -> Stateful { 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, anchor: Rc>>, } /// 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 { 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 { 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; /// Panes side by side or stacked, split by draggable handles. #[derive(IntoElement)] pub struct SplitPane { id: ElementId, axis: Axis, panes: Vec, sizes: Option>, min: Pixels, on_resize: Option, } impl SplitPane { /// `min` is the smallest a pane may shrink to. pub fn new(id: impl Into, 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 = 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, 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); } }