1mod distribute;
39mod handle;
40mod model;
41#[cfg(test)]
42mod tests;
43
44use std::cell::{Cell, RefCell};
45use std::rc::Rc;
46
47use teksilo_canvas::{Point, Rect, Size, SizeProposal};
48use teksilo_core::accessibility::AccessNodeBuilder;
49use teksilo_core::binding::BindingLevel;
50use teksilo_core::build_context::BuildContext;
51use teksilo_core::signal::{Prop, Signal};
52use teksilo_core::styles::{SharedSplitterStyle, SplitterStyle};
53use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
54use teksilo_core::widget_builder::WidgetBuilder;
55use teksilo_core::widget_id::WidgetId;
56use teksilo_tokens::Orientation;
57
58use self::distribute::distribute;
59use self::handle::{SplitterHandle, SplitterHandleConfig};
60
61pub use self::model::{
62 PaneDescriptor, PaneSnapshot, PaneState, SPLITTER_GUTTER_THICKNESS, SPLITTER_KEYBOARD_STEP,
63 SPLITTER_MIN_PANE_SIZE, SPLITTER_SNAP_OFFSET, SplitterModel, SplitterState,
64};
65
66const COLLAPSED_VISIBLE_EPSILON: f32 = 0.01;
71
72pub struct Splitter {
77 model: SplitterModel,
78 enabled: Prop<bool>,
82 style_override: Option<SharedSplitterStyle>,
83 pane_content: Vec<Option<PendingChild>>,
85 pane_labels: Vec<Option<Prop<String>>>,
87 pane_clip_ids: Vec<WidgetId>,
89 pane_inner_ids: Vec<Option<WidgetId>>,
95 pane_full_main: Vec<Rc<Cell<f32>>>,
99 handle_ids: Vec<WidgetId>,
100 progress: Vec<Signal<f32>>,
101 prev_collapsed: Rc<RefCell<Vec<bool>>>,
102 visible_progress: Vec<Signal<f32>>,
105 prev_visible: Rc<RefCell<Vec<bool>>>,
106 container_bounds: Rc<Cell<Rect>>,
108 is_rtl: Rc<Cell<bool>>,
110 layout_sizes: Rc<RefCell<Vec<f32>>>,
113 layout_gutters: Rc<RefCell<Vec<f32>>>,
116}
117
118impl Splitter {
119 pub fn new(model: SplitterModel) -> Self {
122 Self {
123 model,
124 enabled: Prop::Static(true),
125 style_override: None,
126 pane_content: Vec::new(),
127 pane_labels: Vec::new(),
128 pane_clip_ids: Vec::new(),
129 pane_inner_ids: Vec::new(),
130 pane_full_main: Vec::new(),
131 handle_ids: Vec::new(),
132 progress: Vec::new(),
133 prev_collapsed: Rc::new(RefCell::new(Vec::new())),
134 visible_progress: Vec::new(),
135 prev_visible: Rc::new(RefCell::new(Vec::new())),
136 container_bounds: Rc::new(Cell::new(Rect::ZERO)),
137 is_rtl: Rc::new(Cell::new(false)),
138 layout_sizes: Rc::new(RefCell::new(Vec::new())),
139 layout_gutters: Rc::new(RefCell::new(Vec::new())),
140 }
141 }
142
143 pub fn pane(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
146 self.pane_content
147 .push(Some(teksilo_core::IntoTeksiChild::into_pending(widget)));
148 self
149 }
150
151 pub fn panes(self, iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>) -> Self {
156 iter.into_iter().fold(self, Self::pane)
157 }
158
159 pub fn child(self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
162 match teksilo_core::IntoTeksiChild::into_pending(widget) {
163 teksilo_core::PendingChild::Id(id) => self.pane(id),
164 teksilo_core::PendingChild::Deferred(w) => self.pane(w),
165 }
166 }
167
168 pub fn children(
171 self,
172 iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
173 ) -> Self {
174 iter.into_iter().fold(self, Self::child)
175 }
176 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
183 match widget {
184 Some(w) => self.child(w),
185 None => self,
186 }
187 }
188
189 pub fn pane_label(mut self, index: usize, label: impl Into<Prop<String>>) -> Self {
193 if self.pane_labels.len() <= index {
194 self.pane_labels.resize_with(index + 1, || None);
195 }
196 self.pane_labels[index] = Some(label.into());
197 self
198 }
199
200 pub fn style(mut self, style: impl SplitterStyle) -> Self {
202 self.style_override = Some(Rc::new(style));
203 self
204 }
205
206 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
211 self.enabled = enabled.into();
212 self
213 }
214
215 fn resolved_style(&self, ctx: &BuildContext) -> SharedSplitterStyle {
216 self.style_override
217 .clone()
218 .or_else(|| ctx.theme().style_slots.splitter.clone())
219 .unwrap_or_else(|| {
220 Rc::new(crate::styles::RecipeSplitterStyle::for_tokens(
221 &ctx.theme().input,
222 ))
223 })
224 }
225
226 fn main_extent(&self, bounds: Rect) -> f32 {
228 match self.model.orientation() {
229 Orientation::Horizontal => bounds.width,
230 Orientation::Vertical => bounds.height,
231 }
232 }
233}
234
235impl std::fmt::Debug for Splitter {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.debug_struct("Splitter")
238 .field("panes", &self.model.pane_count())
239 .field("orientation", &self.model.orientation())
240 .field("enabled", &self.enabled.get())
241 .finish()
242 }
243}
244
245impl Widget for Splitter {
246 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
247 let self_id = ctx.self_id();
248 let registry = ctx.binding_registry();
249 self.model
251 .version()
252 .bind_to(self_id, registry, BindingLevel::Relayout);
253
254 let style = self.resolved_style(ctx);
255 let gutter = self.model.gutter_thickness();
256 let orientation = self.model.orientation();
257 let n = self.model.pane_count();
258
259 self.progress.clear();
263 let mut initial_collapsed = Vec::with_capacity(n);
264 for i in 0..n {
265 let collapsed = self.model.is_collapsed(i);
266 initial_collapsed.push(collapsed);
267 let prog = ctx.animated_signal(if collapsed { 0.0 } else { 1.0 });
268 let registry = ctx.binding_registry();
269 prog.bind_to(self_id, registry, BindingLevel::Relayout);
270 self.progress.push(prog);
271 }
272 *self.prev_collapsed.borrow_mut() = initial_collapsed;
273
274 self.visible_progress.clear();
276 let mut initial_visible = Vec::with_capacity(n);
277 for i in 0..n {
278 let visible = self.model.is_pane_visible(i);
279 initial_visible.push(visible);
280 let prog = ctx.animated_signal(if visible { 1.0 } else { 0.0 });
281 let registry = ctx.binding_registry();
282 prog.bind_to(self_id, registry, BindingLevel::Relayout);
283 self.visible_progress.push(prog);
284 }
285 *self.prev_visible.borrow_mut() = initial_visible;
286
287 self.pane_clip_ids.clear();
296 self.pane_inner_ids.clear();
297 self.pane_full_main.clear();
298 for i in 0..n {
299 let content = self.pane_content.get_mut(i).and_then(|c| c.take());
300 let child_id = content.map(|pending| match pending {
301 PendingChild::Id(id) => id,
302 PendingChild::Deferred(w) => ctx.add_boxed(w),
303 });
304 self.pane_inner_ids.push(child_id);
305 let effective = self.progress[i]
308 .zip(&self.visible_progress[i])
309 .map(|(c, v)| c * v);
310 let keeps_sliver = self.model.collapsed_size(i) > COLLAPSED_VISIBLE_EPSILON;
315 if let Some(inner) = child_id {
316 let vis = if keeps_sliver {
317 self.visible_progress[i].map(|v| *v > COLLAPSED_VISIBLE_EPSILON)
318 } else {
319 effective.map(|p| *p > COLLAPSED_VISIBLE_EPSILON)
320 };
321 ctx.visible_when(inner, vis);
322 }
323 let full_main = Rc::new(Cell::new(0.0_f32));
324 self.pane_full_main.push(full_main.clone());
325 let label = self.pane_labels.get(i).and_then(|l| l.clone());
326 let clip = ClipPane {
327 child_id,
328 labeled: label.is_some(),
329 effective_progress: Some(effective),
330 full_main,
331 orientation,
332 };
333 let clip_id = match label {
334 Some(lbl) => ctx.add(clip.access_label(lbl)),
335 None => ctx.add(clip),
336 };
337 self.pane_clip_ids.push(clip_id);
338 }
339
340 self.handle_ids.clear();
345 for i in 0..n.saturating_sub(1) {
346 let left = self.pane_clip_ids[i];
347 let right = self.pane_clip_ids[i + 1];
348 let vis_l = self.visible_progress[i].map(|p| *p > COLLAPSED_VISIBLE_EPSILON);
349 let vis_r = self.visible_progress[i + 1].map(|p| *p > COLLAPSED_VISIBLE_EPSILON);
350 let active = vis_l.and(&vis_r);
351 let handle = SplitterHandle::new(SplitterHandleConfig {
352 model: self.model.clone(),
353 index: i,
354 enabled: self.enabled.get(),
355 gutter_thickness: gutter,
356 style: style.clone(),
357 container_bounds: self.container_bounds.clone(),
358 is_rtl: self.is_rtl.clone(),
359 layout_sizes: self.layout_sizes.clone(),
360 layout_gutters: self.layout_gutters.clone(),
361 active: active.clone(),
362 });
363 let handle_id = ctx.add(handle.access_controls(left).access_controls(right));
364 ctx.enabled_when(handle_id, active);
365 self.handle_ids.push(handle_id);
366 }
367
368 let anim = ctx.animate().collapse().standard();
374 let model = self.model.clone();
375 let progress = self.progress.clone();
376 let visible_progress = self.visible_progress.clone();
377 let prev_c = self.prev_collapsed.clone();
378 let prev_v = self.prev_visible.clone();
379 let layout_sizes = self.layout_sizes.clone();
380 ctx.effect(&self.model.version(), move |_| {
381 let animate = model.consume_animate_flag();
382 {
384 let mut prev = prev_c.borrow_mut();
385 let count = progress.len().min(model.pane_count());
386 for i in 0..count {
387 let now = model.is_collapsed(i);
388 if prev.get(i).copied() != Some(now) {
389 if i < prev.len() {
390 prev[i] = now;
391 }
392 if now {
393 if let Some(&disp) = layout_sizes.borrow().get(i)
399 && disp > model.collapsed_size(i)
400 {
401 model.set_stored_size_silent(i, disp);
402 }
403 }
404 let target = if now { 0.0 } else { 1.0 };
405 if animate {
406 anim.to_or_snap(&progress[i], target);
407 } else {
408 progress[i].set(target);
409 }
410 }
411 }
412 }
413 {
415 let mut prev = prev_v.borrow_mut();
416 let count = visible_progress.len().min(model.pane_count());
417 for i in 0..count {
418 let now = model.is_pane_visible(i);
419 if prev.get(i).copied() != Some(now) {
420 if i < prev.len() {
421 prev[i] = now;
422 }
423 let target = if now { 1.0 } else { 0.0 };
424 if animate {
425 anim.to_or_snap(&visible_progress[i], target);
426 } else {
427 visible_progress[i].set(target);
428 }
429 }
430 }
431 }
432 });
433
434 Widget::children(self)
435 }
436
437 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
438 let n = self.pane_clip_ids.len();
439 let gutter = self.model.gutter_thickness();
440 let orientation = self.model.orientation();
441 let total_gutter = (n.saturating_sub(1)) as f32 * gutter;
442
443 let child_proposal = match orientation {
445 Orientation::Horizontal => SizeProposal {
446 width: None,
447 height: proposal.height,
448 },
449 Orientation::Vertical => SizeProposal {
450 width: proposal.width,
451 height: None,
452 },
453 };
454 let mut sum_main = 0.0;
455 let mut max_cross = 0.0_f32;
456 for id in &self.pane_clip_ids {
457 if let Some(sz) = ctx.child_size(*id, child_proposal) {
458 match orientation {
459 Orientation::Horizontal => {
460 sum_main += sz.width;
461 max_cross = max_cross.max(sz.height);
462 }
463 Orientation::Vertical => {
464 sum_main += sz.height;
465 max_cross = max_cross.max(sz.width);
466 }
467 }
468 }
469 }
470 let min_main: f32 = (0..n).map(|i| self.model.min_size(i)).sum::<f32>() + total_gutter;
471 let intrinsic_main = sum_main + total_gutter;
472
473 match orientation {
474 Orientation::Horizontal => Size::new(
475 proposal.width.unwrap_or(intrinsic_main).max(min_main),
476 proposal.height.unwrap_or(max_cross),
477 ),
478 Orientation::Vertical => Size::new(
479 proposal.width.unwrap_or(max_cross),
480 proposal.height.unwrap_or(intrinsic_main).max(min_main),
481 ),
482 }
483 .into()
484 }
485
486 fn place_children(
487 &self,
488 bounds: Rect,
489 _proposal: SizeProposal,
490 children: &mut [WidgetPlacement],
491 ctx: &LayoutContext,
492 ) {
493 self.container_bounds.set(bounds);
494 let orientation = self.model.orientation();
495 let rtl = ctx.is_rtl() && matches!(orientation, Orientation::Horizontal);
496 self.is_rtl.set(rtl);
497
498 let n = self.pane_clip_ids.len();
499 if n == 0 || children.len() != 2 * n - 1 {
500 return;
501 }
502
503 let gutter = self.model.gutter_thickness();
504 let vis_p: Vec<f32> = self
507 .visible_progress
508 .iter()
509 .map(|s| s.get().clamp(0.0, 1.0))
510 .collect();
511 let mut gutter_w = vec![0.0_f32; n - 1];
512 for k in 0..n - 1 {
513 let l = vis_p.get(k).copied().unwrap_or(1.0);
514 let r = vis_p.get(k + 1).copied().unwrap_or(1.0);
515 gutter_w[k] = gutter * l.min(r);
516 }
517 *self.layout_gutters.borrow_mut() = gutter_w.clone();
518 let total_gutter: f32 = gutter_w.iter().sum();
519 let available = (self.main_extent(bounds) - total_gutter).max(0.0);
520
521 let collapse_p: Vec<f32> = self.progress.iter().map(|s| s.get()).collect();
522 let mut snapshots = self.model.pane_snapshots();
527 for (i, s) in snapshots.iter_mut().enumerate() {
528 let vis = vis_p.get(i).copied().unwrap_or(1.0);
529 let prog = collapse_p.get(i).copied().unwrap_or(1.0);
530 let hiding = !s.visible || vis < 1.0 - 0.001;
537 if hiding || prog < 1.0 - 0.001 {
538 s.collapsed = true;
539 }
540 if hiding {
541 s.collapsed_size = 0.0;
542 }
543 }
544 let combined: Vec<f32> = (0..n)
545 .map(|i| {
546 collapse_p.get(i).copied().unwrap_or(1.0) * vis_p.get(i).copied().unwrap_or(1.0)
547 })
548 .collect();
549 let sizes = distribute(available, &snapshots, &combined);
550 *self.layout_sizes.borrow_mut() = sizes.clone();
551
552 let ones = vec![1.0_f32; snapshots.len()];
557 let full_sizes = distribute(available, &snapshots, &ones);
558 for (k, cell) in self.pane_full_main.iter().enumerate() {
559 cell.set(full_sizes.get(k).copied().unwrap_or(0.0));
560 }
561
562 let place = |child: &mut WidgetPlacement, local_start: f32, extent: f32| match orientation {
564 Orientation::Horizontal => {
565 let x = if rtl {
566 bounds.x + bounds.width - local_start - extent
567 } else {
568 bounds.x + local_start
569 };
570 child.origin = Point::new(x, bounds.y);
571 child.size = Size::new(extent, bounds.height);
572 }
573 Orientation::Vertical => {
574 child.origin = Point::new(bounds.x, bounds.y + local_start);
575 child.size = Size::new(bounds.width, extent);
576 }
577 };
578
579 let mut local = 0.0;
580 for k in 0..n {
581 let pane_size = sizes.get(k).copied().unwrap_or(0.0);
582 place(&mut children[2 * k], local, pane_size);
583 local += pane_size;
584 if k < n - 1 {
585 let gw = gutter_w.get(k).copied().unwrap_or(gutter);
586 place(&mut children[2 * k + 1], local, gw);
587 local += gw;
588 }
589 }
590 }
591
592 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
593 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
594 }
595
596 fn children(&self) -> Vec<WidgetId> {
597 let n = self.pane_clip_ids.len();
598 let mut v = Vec::with_capacity(2 * n);
599 for k in 0..n {
600 v.push(self.pane_clip_ids[k]);
601 if k + 1 < n
602 && let Some(h) = self.handle_ids.get(k)
603 {
604 v.push(*h);
605 }
606 }
607 v
608 }
609}
610
611#[derive(Debug)]
618struct ClipPane {
619 child_id: Option<WidgetId>,
620 labeled: bool,
621 effective_progress: Option<Signal<f32>>,
625 full_main: Rc<Cell<f32>>,
629 orientation: Orientation,
630}
631
632impl Widget for ClipPane {
633 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
634 match self.child_id {
635 Some(id) => ctx
636 .child_size(id, proposal)
637 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
638 .into(),
639 None => proposal.resolve(0.0, 0.0).into(),
640 }
641 }
642
643 fn place_children(
644 &self,
645 bounds: Rect,
646 _proposal: SizeProposal,
647 children: &mut [WidgetPlacement],
648 _ctx: &LayoutContext,
649 ) {
650 let full = self.full_main.get();
656 let size = match self.orientation {
657 Orientation::Horizontal => Size::new(full.max(bounds.width), bounds.height),
658 Orientation::Vertical => Size::new(bounds.width, full.max(bounds.height)),
659 };
660 for child in children.iter_mut() {
661 child.origin = bounds.origin();
662 child.size = size;
663 }
664 }
665
666 fn clips_children(&self) -> bool {
667 true
668 }
669
670 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
671 let collapsed = self
672 .effective_progress
673 .as_ref()
674 .map(|p| p.get() <= COLLAPSED_VISIBLE_EPSILON)
675 .unwrap_or(false);
676 if collapsed || !self.labeled {
684 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
685 } else {
686 builder.set_role(teksilo_core::accesskit::Role::Group);
687 }
688 }
689
690 fn children(&self) -> Vec<WidgetId> {
691 self.child_id.into_iter().collect()
692 }
693}