use std::cell::RefCell;
use std::rc::Rc;
use serde::{Deserialize, Serialize};
use teksilo_core::signal::Signal;
use teksilo_settings::Versioned;
use teksilo_tokens::Orientation;
pub const SPLITTER_GUTTER_THICKNESS: f32 = 6.0;
pub const SPLITTER_MIN_PANE_SIZE: f32 = 96.0;
pub const SPLITTER_KEYBOARD_STEP: f32 = 24.0;
pub const SPLITTER_SNAP_OFFSET: f32 = 30.0;
#[derive(Debug, Clone)]
pub struct PaneDescriptor {
pub initial_size: Option<f32>,
pub min_size: f32,
pub max_size: Option<f32>,
pub stretch: f32,
pub collapsible: bool,
pub collapsed: bool,
pub collapsed_size: f32,
pub visible: bool,
}
impl Default for PaneDescriptor {
fn default() -> Self {
Self {
initial_size: None,
min_size: SPLITTER_MIN_PANE_SIZE,
max_size: None,
stretch: 1.0,
collapsible: false,
collapsed: false,
collapsed_size: 0.0,
visible: true,
}
}
}
impl PaneDescriptor {
pub fn new() -> Self {
Self::default()
}
pub fn size(mut self, size: f32) -> Self {
self.initial_size = Some(size);
self
}
pub fn min_size(mut self, min: f32) -> Self {
self.min_size = min;
self
}
pub fn max_size(mut self, max: f32) -> Self {
self.max_size = Some(max);
self
}
pub fn stretch(mut self, stretch: f32) -> Self {
self.stretch = stretch;
self
}
pub fn collapsible(mut self, collapsible: bool) -> Self {
self.collapsible = collapsible;
self
}
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn collapsed_size(mut self, px: f32) -> Self {
self.collapsed_size = px.max(0.0);
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
}
#[derive(Debug, Clone)]
struct PaneEntry {
stored_size: f32,
min_size: f32,
max_size: Option<f32>,
stretch: f32,
collapsible: bool,
collapsed: bool,
collapsed_size: f32,
visible: bool,
}
impl PaneEntry {
fn from_descriptor(d: &PaneDescriptor, fallback_size: f32) -> Self {
let min = d.min_size.max(0.0);
let max = d.max_size.map(|m| m.max(min));
let stored = d.initial_size.unwrap_or(fallback_size).max(0.0);
Self {
stored_size: stored,
min_size: min,
max_size: max,
stretch: d.stretch.max(0.0),
collapsible: d.collapsible,
collapsed: d.collapsed,
collapsed_size: d.collapsed_size.max(0.0),
visible: d.visible,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaneSnapshot {
pub stored_size: f32,
pub min_size: f32,
pub max_size: Option<f32>,
pub stretch: f32,
pub collapsed: bool,
pub collapsed_size: f32,
pub visible: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct PaneState {
pub stored_size: f32,
pub collapsed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SplitterState {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub panes: Vec<PaneState>,
}
fn default_version() -> u32 {
SplitterState::CURRENT_VERSION
}
impl Default for SplitterState {
fn default() -> Self {
Self {
version: SplitterState::CURRENT_VERSION,
panes: Vec::new(),
}
}
}
impl Versioned for SplitterState {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
struct SplitterModelInner {
panes: Vec<PaneEntry>,
orientation: Orientation,
gutter_thickness: f32,
keyboard_step_px: f32,
snap_offset: f32,
version: Signal<u64>,
animate_next_collapse: bool,
}
pub struct SplitterModel(Rc<RefCell<SplitterModelInner>>);
impl Clone for SplitterModel {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl std::fmt::Debug for SplitterModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0.try_borrow() {
Ok(inner) => f
.debug_struct("SplitterModel")
.field("handles", &Rc::strong_count(&self.0))
.field("panes", &inner.panes.len())
.field("orientation", &inner.orientation)
.finish(),
Err(_) => f
.debug_struct("SplitterModel")
.field("handles", &Rc::strong_count(&self.0))
.field("panes", &"<borrowed>")
.finish(),
}
}
}
impl SplitterModel {
pub fn new(n: usize, orientation: Orientation) -> Self {
let panes = (0..n)
.map(|_| PaneEntry::from_descriptor(&PaneDescriptor::default(), 0.0))
.collect();
Self::from_inner(panes, orientation)
}
pub fn from_panes(panes: Vec<PaneDescriptor>, orientation: Orientation) -> Self {
let entries = panes
.iter()
.map(|d| PaneEntry::from_descriptor(d, d.initial_size.unwrap_or(0.0)))
.collect();
Self::from_inner(entries, orientation)
}
fn from_inner(panes: Vec<PaneEntry>, orientation: Orientation) -> Self {
Self(Rc::new(RefCell::new(SplitterModelInner {
panes,
orientation,
gutter_thickness: SPLITTER_GUTTER_THICKNESS,
keyboard_step_px: SPLITTER_KEYBOARD_STEP,
snap_offset: SPLITTER_SNAP_OFFSET,
version: Signal::new(0),
animate_next_collapse: true,
})))
}
pub fn handle_count(&self) -> usize {
Rc::strong_count(&self.0)
}
fn bump_version(&self) {
let version = self.0.borrow().version.clone();
version.set(version.get().wrapping_add(1));
}
pub fn set_stored_size(&self, index: usize, size: f32) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
p.stored_size = size.max(0.0);
}
self.bump_version();
}
pub fn set_stored_size_silent(&self, index: usize, size: f32) {
let mut inner = self.0.borrow_mut();
if let Some(p) = inner.panes.get_mut(index) {
p.stored_size = size.max(0.0);
}
}
pub fn set_pair_sizes(&self, index: usize, size_a: f32, size_b: f32) {
{
let mut inner = self.0.borrow_mut();
if index + 1 >= inner.panes.len() {
return;
}
inner.panes[index].stored_size = size_a.max(0.0);
inner.panes[index + 1].stored_size = size_b.max(0.0);
}
self.bump_version();
}
pub fn set_min_size(&self, index: usize, min: f32) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
p.min_size = min.max(0.0);
if let Some(m) = p.max_size {
p.max_size = Some(m.max(p.min_size));
}
}
self.bump_version();
}
pub fn set_max_size(&self, index: usize, max: Option<f32>) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
p.max_size = max.map(|m| m.max(p.min_size));
}
self.bump_version();
}
pub fn set_stretch(&self, index: usize, stretch: f32) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
p.stretch = stretch.max(0.0);
}
self.bump_version();
}
pub fn set_collapsible(&self, index: usize, collapsible: bool) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
p.collapsible = collapsible;
}
self.bump_version();
}
pub fn set_collapsed(&self, index: usize, collapsed: bool) {
self.set_collapsed_inner(index, collapsed, true);
}
pub fn set_collapsed_immediate(&self, index: usize, collapsed: bool) {
self.set_collapsed_inner(index, collapsed, false);
}
pub fn toggle_collapsed(&self, index: usize) {
let current = self.is_collapsed(index);
self.set_collapsed(index, !current);
}
pub fn set_collapsed_size(&self, index: usize, px: f32) {
let mut inner = self.0.borrow_mut();
if let Some(p) = inner.panes.get_mut(index) {
p.collapsed_size = px.max(0.0);
}
}
fn set_collapsed_inner(&self, index: usize, collapsed: bool, animate: bool) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
if p.collapsed == collapsed {
return; }
p.collapsed = collapsed;
inner.animate_next_collapse = animate;
}
self.bump_version();
}
pub fn set_pane_visible(&self, index: usize, visible: bool) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
if p.visible == visible {
return;
}
p.visible = visible;
inner.animate_next_collapse = true;
}
self.bump_version();
}
pub fn is_pane_visible(&self, index: usize) -> bool {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.visible)
.unwrap_or(false)
}
pub fn consume_animate_flag(&self) -> bool {
let mut inner = self.0.borrow_mut();
let f = inner.animate_next_collapse;
inner.animate_next_collapse = true;
f
}
pub fn insert_pane(&self, index: usize, desc: PaneDescriptor) {
{
let mut inner = self.0.borrow_mut();
let idx = index.min(inner.panes.len());
let fallback = if inner.panes.is_empty() {
SPLITTER_MIN_PANE_SIZE
} else {
inner.panes.iter().map(|p| p.stored_size).sum::<f32>() / inner.panes.len() as f32
};
inner
.panes
.insert(idx, PaneEntry::from_descriptor(&desc, fallback));
}
self.bump_version();
}
pub fn remove_pane(&self, index: usize) {
{
let mut inner = self.0.borrow_mut();
if index >= inner.panes.len() {
return;
}
inner.panes.remove(index);
}
self.bump_version();
}
pub fn replace_pane_desc(&self, index: usize, desc: PaneDescriptor) {
{
let mut inner = self.0.borrow_mut();
let Some(p) = inner.panes.get_mut(index) else {
return;
};
let fallback = p.stored_size;
*p = PaneEntry::from_descriptor(&desc, fallback);
}
self.bump_version();
}
pub fn set_gutter_thickness(&self, thickness: f32) {
{
self.0.borrow_mut().gutter_thickness = thickness.max(1.0);
}
self.bump_version();
}
pub fn set_snap_offset(&self, offset: f32) {
{
self.0.borrow_mut().snap_offset = offset.max(0.0);
}
self.bump_version();
}
pub fn set_keyboard_step_px(&self, step: f32) {
{
self.0.borrow_mut().keyboard_step_px = step.max(1.0);
}
self.bump_version();
}
pub fn set_orientation(&self, orientation: Orientation) {
{
self.0.borrow_mut().orientation = orientation;
}
self.bump_version();
}
pub fn pane_count(&self) -> usize {
self.0.borrow().panes.len()
}
pub fn stored_size(&self, index: usize) -> f32 {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.stored_size)
.unwrap_or(0.0)
}
pub fn min_size(&self, index: usize) -> f32 {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.min_size)
.unwrap_or(0.0)
}
pub fn max_size(&self, index: usize) -> Option<f32> {
self.0.borrow().panes.get(index).and_then(|p| p.max_size)
}
pub fn stretch(&self, index: usize) -> f32 {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.stretch)
.unwrap_or(0.0)
}
pub fn is_collapsible(&self, index: usize) -> bool {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.collapsible)
.unwrap_or(false)
}
pub fn collapsed_size(&self, index: usize) -> f32 {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.collapsed_size)
.unwrap_or(0.0)
}
pub fn is_collapsed(&self, index: usize) -> bool {
self.0
.borrow()
.panes
.get(index)
.map(|p| p.collapsed)
.unwrap_or(false)
}
pub fn orientation(&self) -> Orientation {
self.0.borrow().orientation
}
pub fn gutter_thickness(&self) -> f32 {
self.0.borrow().gutter_thickness
}
pub fn snap_offset(&self) -> f32 {
self.0.borrow().snap_offset
}
pub fn keyboard_step_px(&self) -> f32 {
self.0.borrow().keyboard_step_px
}
pub fn version(&self) -> Signal<u64> {
self.0.borrow().version.clone()
}
pub fn pane_snapshots(&self) -> Vec<PaneSnapshot> {
self.0
.borrow()
.panes
.iter()
.map(|p| PaneSnapshot {
stored_size: p.stored_size,
min_size: p.min_size,
max_size: p.max_size,
stretch: p.stretch,
collapsed: p.collapsed,
collapsed_size: p.collapsed_size,
visible: p.visible,
})
.collect()
}
pub fn export_state(&self) -> SplitterState {
let inner = self.0.borrow();
SplitterState {
version: SplitterState::CURRENT_VERSION,
panes: inner
.panes
.iter()
.map(|p| PaneState {
stored_size: p.stored_size,
collapsed: p.collapsed,
})
.collect(),
}
}
pub fn import_state(&self, state: &SplitterState) -> bool {
let ok = {
let mut inner = self.0.borrow_mut();
if state.panes.len() != inner.panes.len() {
false
} else {
for (p, s) in inner.panes.iter_mut().zip(&state.panes) {
p.stored_size = s.stored_size.max(0.0);
p.collapsed = s.collapsed;
}
inner.animate_next_collapse = false;
true
}
};
if ok {
self.bump_version();
}
ok
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clone_shares_state() {
let a = SplitterModel::new(3, Orientation::Horizontal);
let b = a.clone();
assert_eq!(b.pane_count(), 3);
a.set_stored_size(0, 200.0);
assert_eq!(b.stored_size(0), 200.0);
assert_eq!(a.handle_count(), 2);
}
#[test]
fn version_bumps_on_mutation() {
let m = SplitterModel::new(2, Orientation::Horizontal);
let v = m.version();
let v0 = v.get();
m.set_stored_size(0, 150.0);
assert_ne!(v.get(), v0);
let v1 = v.get();
m.set_collapsed(0, false); assert_eq!(v.get(), v1);
}
#[test]
fn export_import_round_trips() {
let m = SplitterModel::new(3, Orientation::Horizontal);
m.set_stored_size(0, 120.0);
m.set_stored_size(1, 340.0);
m.set_collapsed(2, true);
let state = m.export_state();
let restored = SplitterModel::new(3, Orientation::Horizontal);
assert!(restored.import_state(&state));
assert_eq!(restored.stored_size(0), 120.0);
assert_eq!(restored.stored_size(1), 340.0);
assert!(restored.is_collapsed(2));
}
#[test]
fn import_rejects_pane_count_mismatch() {
let m = SplitterModel::new(3, Orientation::Horizontal);
let state = m.export_state();
let two = SplitterModel::new(2, Orientation::Horizontal);
assert!(!two.import_state(&state));
}
#[test]
fn insert_remove_change_count() {
let m = SplitterModel::new(2, Orientation::Horizontal);
m.insert_pane(1, PaneDescriptor::new().size(100.0));
assert_eq!(m.pane_count(), 3);
assert_eq!(m.stored_size(1), 100.0);
m.remove_pane(0);
assert_eq!(m.pane_count(), 2);
}
#[test]
fn max_size_enforced_ge_min() {
let m = SplitterModel::from_panes(
vec![PaneDescriptor::new().min_size(200.0).max_size(100.0)],
Orientation::Horizontal,
);
assert_eq!(m.max_size(0), Some(200.0));
}
#[test]
fn animate_flag_consumed_and_resets() {
let m = SplitterModel::new(2, Orientation::Horizontal);
m.set_collapsed_immediate(0, true);
assert!(!m.consume_animate_flag()); assert!(m.consume_animate_flag());
}
}