use crate::core::{Orientation, Rect};
use crate::event::{DragPayload, DragSession};
use crate::layout::{splitter::SplitterLayout, Layout};
use crate::object::ObjectId;
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_orientation, orientation_to_str};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, SimpleRegistry, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::cell::RefCell;
use std::rc::Rc;
const SPLITTER_DRAG_TYPE: &str = "splitter_handle";
const SPLITTER_DRAG_THRESHOLD: i32 = 2;
#[derive(Debug, Clone)]
struct HandleDrag {
handle_index: usize,
start_ratios: Vec<f32>,
}
pub struct Splitter {
base: BaseWidget,
layout: SplitterLayout,
pub pane_layout_changed: Signal1<Vec<f32>>,
pub orientation_changed: Signal1<Orientation>,
registry: Option<Rc<RefCell<SimpleRegistry>>>,
drag_session: Option<DragSession>,
drag_state: Option<HandleDrag>,
active_pane: Option<usize>,
}
impl Splitter {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Splitter, geometry, "Splitter"),
layout: SplitterLayout::new(Orientation::Horizontal, 0),
pane_layout_changed: Signal1::new(),
orientation_changed: Signal1::new(),
registry: None,
drag_session: None,
drag_state: None,
active_pane: None,
}
}
pub fn orientation(&self) -> Orientation {
self.layout.orientation()
}
pub fn set_orientation(&mut self, orientation: Orientation) {
if self.layout.orientation() == orientation {
return;
}
self.layout.set_orientation(orientation);
self.orientation_changed.emit(orientation);
self.base.request_redraw();
}
pub fn pane_count(&self) -> usize {
self.layout.pane_count()
}
pub fn pane_ids(&self) -> &[ObjectId] {
self.layout.pane_ids()
}
pub fn ratio(&self, index: usize) -> Option<f32> {
self.layout.ratio(index)
}
pub fn add_pane(&mut self, pane_id: ObjectId, stretch: u32) -> usize {
let index = self.layout.add_pane(pane_id, stretch);
if self.pane_layout_changed.slot_count() > 0 {
self.pane_layout_changed.emit(self.layout.ratios().to_vec());
}
index
}
pub fn remove_pane(&mut self, pane_id: ObjectId) -> bool {
if !self.layout.remove_pane(pane_id) {
return false;
}
if self.pane_layout_changed.slot_count() > 0 {
self.pane_layout_changed.emit(self.layout.ratios().to_vec());
}
true
}
pub fn set_ratio(&mut self, index: usize, ratio: f32) -> bool {
if !self.layout.set_ratio(index, ratio) {
return false;
}
if self.pane_layout_changed.slot_count() > 0 {
self.pane_layout_changed.emit(self.layout.ratios().to_vec());
}
self.base.request_redraw();
true
}
pub fn set_ratios(&mut self, ratios: Vec<f32>) -> bool {
if !self.layout.set_ratios(ratios) {
return false;
}
if self.pane_layout_changed.slot_count() > 0 {
self.pane_layout_changed.emit(self.layout.ratios().to_vec());
}
self.base.request_redraw();
true
}
pub fn normalize_ratios(&mut self) {
self.layout.normalize_ratios();
}
pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
self.registry = Some(registry);
self.base.request_redraw();
}
fn pane_rects(&self) -> Vec<(ObjectId, Rect)> {
let mut rects = Vec::new();
self.layout.update(self.base.geometry(), &mut |id, rect| rects.push((id, rect)));
rects
}
}
impl Widget for Splitter {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(300, 200)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Splitter {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"orientation" => {
Ok(CapabilityValue::String(orientation_to_str(self.orientation()).to_string()))
}
"pane_count" => Ok(CapabilityValue::UInt(self.pane_count() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"orientation" => {
self.set_orientation(expect_orientation(value)?);
Ok(())
}
"pane_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["orientation", "pane_count", BASE_PROPERTY_NAMES]
}
}
impl Draw for Splitter {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
if let Some(ref registry) = self.registry {
for (pane_id, pane_rect) in self.pane_rects() {
context.push_clip(pane_rect.x, pane_rect.y, pane_rect.width, pane_rect.height);
registry.borrow_mut().draw_widget(pane_id, context);
context.pop_clip();
}
}
let handle_width = 5;
match self.orientation() {
Orientation::Horizontal => {
if self.pane_count() > 1 {
let total_width = rect.width as f32;
let mut x = rect.x as f32;
for i in 0..self.pane_count() - 1 {
let ratio = self.ratio(i).unwrap_or(0.0);
x += total_width * ratio;
let handle_rect = Rect::new(
x as i32 - handle_width / 2,
rect.y,
handle_width as u32,
rect.height,
);
context.fill_rect(handle_rect, crate::core::Color::rgb(200, 200, 200));
context.draw_rect(handle_rect, crate::core::Color::rgb(150, 150, 150));
}
}
}
Orientation::Vertical => {
if self.pane_count() > 1 {
let total_height = rect.height as f32;
let mut y = rect.y as f32;
for i in 0..self.pane_count() - 1 {
let ratio = self.ratio(i).unwrap_or(0.0);
y += total_height * ratio;
let handle_rect = Rect::new(
rect.x,
y as i32 - handle_width / 2,
rect.width,
handle_width as u32,
);
context.fill_rect(handle_rect, crate::core::Color::rgb(200, 200, 200));
context.draw_rect(handle_rect, crate::core::Color::rgb(150, 150, 150));
}
}
}
}
}
}
impl crate::event::EventHandler for Splitter {
fn handle_event(&mut self, event: &crate::event::Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
crate::event::Event::MousePress { pos, button }
if *button == 1 && self.pane_count() > 1 =>
{
self.begin_handle_drag(*pos);
}
crate::event::Event::MouseMove { pos } if self.drag_session.is_some() => {
self.update_handle_drag(*pos);
}
crate::event::Event::MouseRelease { button, .. } if *button == 1 => {
self.end_handle_drag();
}
_ => {}
}
if let Some(ref reg) = self.registry {
let target = match event {
crate::event::Event::MousePress { pos, .. }
| crate::event::Event::MouseRelease { pos, .. }
| crate::event::Event::MouseMove { pos } => {
self.pane_rects().iter().position(|(_, pane)| {
pos.x >= pane.x
&& pos.x < pane.x + pane.width as i32
&& pos.y >= pane.y
&& pos.y < pane.y + pane.height as i32
})
}
_ => self.active_pane,
};
if let Some(index) = target {
if let Some(pane_id) = self.pane_ids().get(index) {
let _ = reg.borrow_mut().forward_event(*pane_id, event);
}
}
}
}
}
impl Splitter {
fn primary_extent(&self) -> f32 {
let rect = self.base.geometry();
if self.orientation() == Orientation::Horizontal {
rect.width as f32
} else {
rect.height as f32
}
}
fn primary_offset(&self, pos: crate::core::Point) -> f32 {
let rect = self.base.geometry();
if self.orientation() == Orientation::Horizontal {
pos.x as f32 - rect.x as f32
} else {
pos.y as f32 - rect.y as f32
}
}
fn begin_handle_drag(&mut self, pos: crate::core::Point) {
const HANDLE_WIDTH: f32 = 5.0;
if let Some(index) = self.pane_rects().iter().position(|(_, pane)| {
pos.x >= pane.x
&& pos.x < pane.x + pane.width as i32
&& pos.y >= pane.y
&& pos.y < pane.y + pane.height as i32
}) {
self.active_pane = Some(index);
}
let total = self.primary_extent();
let pos_primary = self.primary_offset(pos);
let mut accumulated = 0.0;
for index in 0..self.pane_count().saturating_sub(1) {
if let Some(ratio) = self.ratio(index) {
accumulated += ratio * total;
}
if (pos_primary - accumulated).abs() <= HANDLE_WIDTH / 2.0 {
let payload =
DragPayload::new(SPLITTER_DRAG_TYPE, index.to_string()).with_origin(pos);
self.drag_session = Some(DragSession::begin(payload, pos));
self.drag_state = Some(HandleDrag {
handle_index: index,
start_ratios: self.layout.ratios().to_vec(),
});
break;
}
}
}
fn update_handle_drag(&mut self, pos: crate::core::Point) {
let (is_active, start) = {
let Some(session) = self.drag_session.as_mut() else {
return;
};
session.update(pos, SPLITTER_DRAG_THRESHOLD);
(session.is_active(), session.start())
};
if !is_active {
return;
}
let Some(snapshot) = self.drag_state.clone() else {
return;
};
let total = self.primary_extent();
if total <= 0.0 {
return;
}
let delta = self.primary_offset(pos) - self.primary_offset(start);
let index = snapshot.handle_index;
let left = snapshot.start_ratios.get(index).copied().unwrap_or(0.0);
let right = snapshot.start_ratios.get(index + 1).copied().unwrap_or(0.0);
let ratio_delta = delta / total;
let new_left = (left + ratio_delta).max(0.0);
let new_right = (right - ratio_delta).max(0.0);
let pair_sum = left + right;
if pair_sum <= 0.0 {
return;
}
let new_pair_sum = new_left + new_right;
if new_pair_sum <= 0.0 {
return;
}
let scale = pair_sum / new_pair_sum;
self.layout.set_ratio(index, new_left * scale);
self.layout.set_ratio(index + 1, new_right * scale);
if self.pane_layout_changed.slot_count() > 0 {
self.pane_layout_changed.emit(self.layout.ratios().to_vec());
}
}
fn end_handle_drag(&mut self) {
if self.drag_session.take().is_none() {
return;
}
self.drag_state = None;
self.layout.normalize_ratios();
if self.pane_layout_changed.slot_count() > 0 {
self.pane_layout_changed.emit(self.layout.ratios().to_vec());
}
}
pub fn is_dragging_handle(&self) -> bool {
self.drag_session.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Orientation, Rect};
use crate::event::EventHandler as _;
use crate::object::ObjectId;
#[test]
fn splitter_creation_defaults() {
let sp = Splitter::new(Rect::new(0, 0, 300, 200));
assert_eq!(sp.geometry(), Rect::new(0, 0, 300, 200));
assert_eq!(sp.orientation(), Orientation::Horizontal);
assert_eq!(sp.pane_count(), 0);
}
#[test]
fn splitter_add_and_remove_pane() {
let mut sp = Splitter::new(Rect::new(0, 0, 300, 200));
let pane_id: ObjectId = 1;
let idx = sp.add_pane(pane_id, 1);
assert_eq!(idx, 0);
assert_eq!(sp.pane_count(), 1);
assert_eq!(sp.pane_ids(), &[1]);
assert!(sp.remove_pane(pane_id));
assert_eq!(sp.pane_count(), 0);
}
#[test]
fn splitter_set_orientation() {
let mut sp = Splitter::new(Rect::new(0, 0, 300, 200));
sp.set_orientation(Orientation::Vertical);
assert_eq!(sp.orientation(), Orientation::Vertical);
}
fn two_pane_splitter() -> Splitter {
let mut sp = Splitter::new(Rect::new(0, 0, 300, 200));
sp.add_pane(1, 1);
sp.add_pane(2, 1);
sp
}
fn divider_x(sp: &Splitter) -> i32 {
let total = sp.geometry().width as f32;
(sp.ratio(0).unwrap_or(0.0) * total).round() as i32 + sp.geometry().x
}
#[test]
fn splitter_drag_opens_and_closes_a_session() {
let mut sp = two_pane_splitter();
assert!(!sp.is_dragging_handle());
sp.handle_event(&crate::event::Event::mouse_press(divider_x(&sp), 100, 1));
assert!(sp.is_dragging_handle(), "pressing the divider opens a drag session");
sp.handle_event(&crate::event::Event::mouse_release(50, 100, 1));
assert!(!sp.is_dragging_handle(), "releasing closes it");
}
#[test]
fn splitter_press_away_from_a_divider_opens_no_session() {
let mut sp = two_pane_splitter();
sp.handle_event(&crate::event::Event::mouse_press(20, 100, 1));
assert!(!sp.is_dragging_handle());
}
#[test]
fn splitter_drag_moves_the_ratio_towards_the_pointer() {
let mut sp = two_pane_splitter();
let before = sp.ratio(0).expect("ratio 0");
let divider = divider_x(&sp);
sp.handle_event(&crate::event::Event::mouse_press(divider, 100, 1));
sp.handle_event(&crate::event::Event::mouse_move(divider + 60, 100));
let during = sp.ratio(0).expect("ratio 0");
assert!(during > before, "a rightward drag grows the leading pane: {before} -> {during}");
}
#[test]
fn splitter_drag_below_the_threshold_does_not_resize() {
let mut sp = two_pane_splitter();
let before = sp.ratio(0).expect("ratio 0");
let divider = divider_x(&sp);
sp.handle_event(&crate::event::Event::mouse_press(divider, 100, 1));
sp.handle_event(&crate::event::Event::mouse_move(divider + 1, 100));
assert_eq!(
sp.ratio(0).expect("ratio 0"),
before,
"a sub-threshold move must not resize anything"
);
}
#[test]
fn splitter_drag_cannot_invert_the_pane_pair() {
let mut sp = two_pane_splitter();
let divider = divider_x(&sp);
let start_pair_sum = sp.ratio(0).expect("ratio 0") + sp.ratio(1).expect("ratio 1");
sp.handle_event(&crate::event::Event::mouse_press(divider, 100, 1));
sp.handle_event(&crate::event::Event::mouse_move(divider + 500, 100));
let first = sp.ratio(0).expect("ratio 0");
let second = sp.ratio(1).expect("ratio 1");
assert!(first >= 0.0, "a ratio cannot go negative: {first}");
assert!(second >= 0.0, "a ratio cannot go negative: {second}");
assert_eq!(second, 0.0, "dragging past the edge collapses the trailing pane");
assert!(
(first + second - start_pair_sum).abs() < 0.01,
"a drag preserves the pair's total weight: {first} + {second} vs {start_pair_sum}"
);
sp.handle_event(&crate::event::Event::mouse_release(divider + 500, 100, 1));
let first = sp.ratio(0).expect("ratio 0");
let second = sp.ratio(1).expect("ratio 1");
assert!(
(first + second - 1.0).abs() < 0.01,
"releasing normalises the pair to one: {first} + {second} = {}",
first + second
);
assert!(first > 0.99, "the leading pane took the whole width: {first}");
}
#[test]
fn splitter_orientation_selects_which_axis_a_handle_moves_on() {
let mut sp = Splitter::new(Rect::new(0, 0, 300, 200));
sp.set_orientation(Orientation::Vertical);
sp.add_pane(1, 1);
sp.add_pane(2, 1);
let total = sp.geometry().height as f32;
let divider_y = (sp.ratio(0).expect("ratio 0") * total).round() as i32;
let before = sp.ratio(0).expect("ratio 0");
sp.handle_event(&crate::event::Event::mouse_press(100, divider_y, 1));
sp.handle_event(&crate::event::Event::mouse_move(100, divider_y + 40));
assert!(sp.ratio(0).expect("ratio 0") > before, "a vertical splitter's handle moves on y");
}
#[test]
fn splitter_disabled_ignores_a_drag() {
let mut sp = two_pane_splitter();
let before = sp.ratio(0).expect("ratio 0");
let divider = divider_x(&sp);
sp.set_enabled(false);
sp.handle_event(&crate::event::Event::mouse_press(divider, 100, 1));
assert!(!sp.is_dragging_handle(), "a disabled splitter must not start a drag");
sp.handle_event(&crate::event::Event::mouse_move(divider + 60, 100));
assert_eq!(sp.ratio(0).expect("ratio 0"), before);
}
#[test]
fn splitter_repeated_move_is_idempotent() {
let mut sp = two_pane_splitter();
let divider = divider_x(&sp);
sp.handle_event(&crate::event::Event::mouse_press(divider, 100, 1));
sp.handle_event(&crate::event::Event::mouse_move(divider + 40, 100));
let once = sp.ratio(0).expect("ratio 0");
sp.handle_event(&crate::event::Event::mouse_move(divider + 40, 100));
let twice = sp.ratio(0).expect("ratio 0");
assert!(
(once - twice).abs() < f32::EPSILON,
"a re-delivered move must be a no-op: {once} vs {twice}"
);
}
}