1mod distribute;
34mod handle;
35mod model;
36#[cfg(test)]
37mod tests;
38
39use std::cell::{Cell, RefCell};
40use std::rc::Rc;
41
42use teksilo_canvas::{Point, Rect, Size, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::binding::BindingLevel;
45use teksilo_core::build_context::BuildContext;
46use teksilo_core::signal::{Prop, Signal};
47use teksilo_core::styles::{SharedSplitterStyle, SplitterStyle};
48use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
49use teksilo_core::widget_builder::WidgetBuilder;
50use teksilo_core::widget_id::WidgetId;
51use teksilo_tokens::Orientation;
52
53use self::distribute::distribute;
54use self::handle::{SplitterHandle, SplitterHandleConfig};
55
56pub use self::model::{
57 PaneDescriptor, PaneSnapshot, PaneState, SPLITTER_GUTTER_THICKNESS, SPLITTER_KEYBOARD_STEP,
58 SPLITTER_MIN_PANE_SIZE, SPLITTER_SNAP_OFFSET, SplitterModel, SplitterState,
59};
60
61const COLLAPSED_VISIBLE_EPSILON: f32 = 0.01;
66
67pub struct Splitter {
72 model: SplitterModel,
73 enabled: Prop<bool>,
77 style_override: Option<SharedSplitterStyle>,
78 pane_content: Vec<Option<PendingChild>>,
80 pane_labels: Vec<Option<Prop<String>>>,
82 pane_clip_ids: Vec<WidgetId>,
84 pane_inner_ids: Vec<Option<WidgetId>>,
88 pane_full_main: Vec<Rc<Cell<f32>>>,
92 handle_ids: Vec<WidgetId>,
93 progress: Vec<Signal<f32>>,
94 prev_collapsed: Rc<RefCell<Vec<bool>>>,
95 visible_progress: Vec<Signal<f32>>,
98 prev_visible: Rc<RefCell<Vec<bool>>>,
99 container_bounds: Rc<Cell<Rect>>,
101 is_rtl: Rc<Cell<bool>>,
103 layout_sizes: Rc<RefCell<Vec<f32>>>,
106 layout_gutters: Rc<RefCell<Vec<f32>>>,
109}
110
111impl Splitter {
112 pub fn new(model: SplitterModel) -> Self {
115 Self {
116 model,
117 enabled: Prop::Static(true),
118 style_override: None,
119 pane_content: Vec::new(),
120 pane_labels: Vec::new(),
121 pane_clip_ids: Vec::new(),
122 pane_inner_ids: Vec::new(),
123 pane_full_main: Vec::new(),
124 handle_ids: Vec::new(),
125 progress: Vec::new(),
126 prev_collapsed: Rc::new(RefCell::new(Vec::new())),
127 visible_progress: Vec::new(),
128 prev_visible: Rc::new(RefCell::new(Vec::new())),
129 container_bounds: Rc::new(Cell::new(Rect::ZERO)),
130 is_rtl: Rc::new(Cell::new(false)),
131 layout_sizes: Rc::new(RefCell::new(Vec::new())),
132 layout_gutters: Rc::new(RefCell::new(Vec::new())),
133 }
134 }
135
136 pub fn pane(mut self, widget: impl Widget + 'static) -> Self {
139 self.pane_content
140 .push(Some(PendingChild::Deferred(Box::new(widget))));
141 self
142 }
143
144 pub fn pane_id(mut self, id: WidgetId) -> Self {
146 self.pane_content.push(Some(PendingChild::Id(id)));
147 self
148 }
149
150 pub fn child(self, widget: impl Widget + 'static) -> Self {
153 self.pane(widget)
154 }
155
156 pub fn pane_label(mut self, index: usize, label: impl Into<Prop<String>>) -> Self {
160 if self.pane_labels.len() <= index {
161 self.pane_labels.resize_with(index + 1, || None);
162 }
163 self.pane_labels[index] = Some(label.into());
164 self
165 }
166
167 pub fn style(mut self, style: impl SplitterStyle) -> Self {
169 self.style_override = Some(Rc::new(style));
170 self
171 }
172
173 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
177 self.enabled = enabled.into();
178 self
179 }
180
181 fn resolved_style(&self, ctx: &BuildContext) -> SharedSplitterStyle {
182 self.style_override
183 .clone()
184 .or_else(|| ctx.theme().style_slots.splitter.clone())
185 .unwrap_or_else(|| Rc::new(crate::styles::RecipeSplitterStyle::default()))
186 }
187
188 fn main_extent(&self, bounds: Rect) -> f32 {
190 match self.model.orientation() {
191 Orientation::Horizontal => bounds.width,
192 Orientation::Vertical => bounds.height,
193 }
194 }
195}
196
197impl std::fmt::Debug for Splitter {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 f.debug_struct("Splitter")
200 .field("panes", &self.model.pane_count())
201 .field("orientation", &self.model.orientation())
202 .field("enabled", &self.enabled.get())
203 .finish()
204 }
205}
206
207impl Widget for Splitter {
208 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
209 let self_id = ctx.self_id();
210 let registry = ctx.binding_registry();
211 self.model
213 .version()
214 .bind_to(self_id, registry, BindingLevel::Relayout);
215
216 let style = self.resolved_style(ctx);
217 let gutter = self.model.gutter_thickness();
218 let orientation = self.model.orientation();
219 let n = self.model.pane_count();
220
221 self.progress.clear();
225 let mut initial_collapsed = Vec::with_capacity(n);
226 for i in 0..n {
227 let collapsed = self.model.is_collapsed(i);
228 initial_collapsed.push(collapsed);
229 let prog = ctx.animated_signal(if collapsed { 0.0 } else { 1.0 });
230 let registry = ctx.binding_registry();
231 prog.bind_to(self_id, registry, BindingLevel::Relayout);
232 self.progress.push(prog);
233 }
234 *self.prev_collapsed.borrow_mut() = initial_collapsed;
235
236 self.visible_progress.clear();
238 let mut initial_visible = Vec::with_capacity(n);
239 for i in 0..n {
240 let visible = self.model.is_pane_visible(i);
241 initial_visible.push(visible);
242 let prog = ctx.animated_signal(if visible { 1.0 } else { 0.0 });
243 let registry = ctx.binding_registry();
244 prog.bind_to(self_id, registry, BindingLevel::Relayout);
245 self.visible_progress.push(prog);
246 }
247 *self.prev_visible.borrow_mut() = initial_visible;
248
249 self.pane_clip_ids.clear();
258 self.pane_inner_ids.clear();
259 self.pane_full_main.clear();
260 for i in 0..n {
261 let content = self.pane_content.get_mut(i).and_then(|c| c.take());
262 let child_id = content.map(|pending| match pending {
263 PendingChild::Id(id) => id,
264 PendingChild::Deferred(w) => ctx.add_boxed(w),
265 });
266 self.pane_inner_ids.push(child_id);
267 let effective = self.progress[i]
270 .zip(&self.visible_progress[i])
271 .map(|(c, v)| c * v);
272 let keeps_sliver = self.model.collapsed_size(i) > COLLAPSED_VISIBLE_EPSILON;
277 if let Some(inner) = child_id {
278 let vis = if keeps_sliver {
279 self.visible_progress[i].map(|v| *v > COLLAPSED_VISIBLE_EPSILON)
280 } else {
281 effective.map(|p| *p > COLLAPSED_VISIBLE_EPSILON)
282 };
283 ctx.visible_when(inner, vis);
284 }
285 let full_main = Rc::new(Cell::new(0.0_f32));
286 self.pane_full_main.push(full_main.clone());
287 let label = self.pane_labels.get(i).and_then(|l| l.clone());
288 let clip = ClipPane {
289 child_id,
290 labeled: label.is_some(),
291 effective_progress: Some(effective),
292 full_main,
293 orientation,
294 };
295 let clip_id = match label {
296 Some(lbl) => ctx.add(clip.access_label(lbl)),
297 None => ctx.add(clip),
298 };
299 self.pane_clip_ids.push(clip_id);
300 }
301
302 self.handle_ids.clear();
307 for i in 0..n.saturating_sub(1) {
308 let left = self.pane_clip_ids[i];
309 let right = self.pane_clip_ids[i + 1];
310 let vis_l = self.visible_progress[i].map(|p| *p > COLLAPSED_VISIBLE_EPSILON);
311 let vis_r = self.visible_progress[i + 1].map(|p| *p > COLLAPSED_VISIBLE_EPSILON);
312 let active = vis_l.and(&vis_r);
313 let handle = SplitterHandle::new(SplitterHandleConfig {
314 model: self.model.clone(),
315 index: i,
316 enabled: self.enabled.get(),
317 gutter_thickness: gutter,
318 style: style.clone(),
319 container_bounds: self.container_bounds.clone(),
320 is_rtl: self.is_rtl.clone(),
321 layout_sizes: self.layout_sizes.clone(),
322 layout_gutters: self.layout_gutters.clone(),
323 active: active.clone(),
324 });
325 let handle_id = ctx.add(handle.access_controls(left).access_controls(right));
326 ctx.enabled_when(handle_id, active);
327 self.handle_ids.push(handle_id);
328 }
329
330 let anim = ctx.animate().collapse().standard();
336 let model = self.model.clone();
337 let progress = self.progress.clone();
338 let visible_progress = self.visible_progress.clone();
339 let prev_c = self.prev_collapsed.clone();
340 let prev_v = self.prev_visible.clone();
341 let layout_sizes = self.layout_sizes.clone();
342 ctx.effect(&self.model.version(), move |_| {
343 let animate = model.consume_animate_flag();
344 {
346 let mut prev = prev_c.borrow_mut();
347 let count = progress.len().min(model.pane_count());
348 for i in 0..count {
349 let now = model.is_collapsed(i);
350 if prev.get(i).copied() != Some(now) {
351 if i < prev.len() {
352 prev[i] = now;
353 }
354 if now {
355 if let Some(&disp) = layout_sizes.borrow().get(i)
361 && disp > model.collapsed_size(i)
362 {
363 model.set_stored_size_silent(i, disp);
364 }
365 }
366 let target = if now { 0.0 } else { 1.0 };
367 if animate {
368 anim.to_or_snap(&progress[i], target);
369 } else {
370 progress[i].set(target);
371 }
372 }
373 }
374 }
375 {
377 let mut prev = prev_v.borrow_mut();
378 let count = visible_progress.len().min(model.pane_count());
379 for i in 0..count {
380 let now = model.is_pane_visible(i);
381 if prev.get(i).copied() != Some(now) {
382 if i < prev.len() {
383 prev[i] = now;
384 }
385 let target = if now { 1.0 } else { 0.0 };
386 if animate {
387 anim.to_or_snap(&visible_progress[i], target);
388 } else {
389 visible_progress[i].set(target);
390 }
391 }
392 }
393 }
394 });
395
396 self.children()
397 }
398
399 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
400 let n = self.pane_clip_ids.len();
401 let gutter = self.model.gutter_thickness();
402 let orientation = self.model.orientation();
403 let total_gutter = (n.saturating_sub(1)) as f32 * gutter;
404
405 let child_proposal = match orientation {
407 Orientation::Horizontal => SizeProposal {
408 width: None,
409 height: proposal.height,
410 },
411 Orientation::Vertical => SizeProposal {
412 width: proposal.width,
413 height: None,
414 },
415 };
416 let mut sum_main = 0.0;
417 let mut max_cross = 0.0_f32;
418 for id in &self.pane_clip_ids {
419 if let Some(sz) = ctx.child_size(*id, child_proposal) {
420 match orientation {
421 Orientation::Horizontal => {
422 sum_main += sz.width;
423 max_cross = max_cross.max(sz.height);
424 }
425 Orientation::Vertical => {
426 sum_main += sz.height;
427 max_cross = max_cross.max(sz.width);
428 }
429 }
430 }
431 }
432 let min_main: f32 = (0..n).map(|i| self.model.min_size(i)).sum::<f32>() + total_gutter;
433 let intrinsic_main = sum_main + total_gutter;
434
435 match orientation {
436 Orientation::Horizontal => Size::new(
437 proposal.width.unwrap_or(intrinsic_main).max(min_main),
438 proposal.height.unwrap_or(max_cross),
439 ),
440 Orientation::Vertical => Size::new(
441 proposal.width.unwrap_or(max_cross),
442 proposal.height.unwrap_or(intrinsic_main).max(min_main),
443 ),
444 }
445 .into()
446 }
447
448 fn place_children(
449 &self,
450 bounds: Rect,
451 _proposal: SizeProposal,
452 children: &mut [WidgetPlacement],
453 ctx: &LayoutContext,
454 ) {
455 self.container_bounds.set(bounds);
456 let orientation = self.model.orientation();
457 let rtl = ctx.is_rtl() && matches!(orientation, Orientation::Horizontal);
458 self.is_rtl.set(rtl);
459
460 let n = self.pane_clip_ids.len();
461 if n == 0 || children.len() != 2 * n - 1 {
462 return;
463 }
464
465 let gutter = self.model.gutter_thickness();
466 let vis_p: Vec<f32> = self
469 .visible_progress
470 .iter()
471 .map(|s| s.get().clamp(0.0, 1.0))
472 .collect();
473 let mut gutter_w = vec![0.0_f32; n - 1];
474 for k in 0..n - 1 {
475 let l = vis_p.get(k).copied().unwrap_or(1.0);
476 let r = vis_p.get(k + 1).copied().unwrap_or(1.0);
477 gutter_w[k] = gutter * l.min(r);
478 }
479 *self.layout_gutters.borrow_mut() = gutter_w.clone();
480 let total_gutter: f32 = gutter_w.iter().sum();
481 let available = (self.main_extent(bounds) - total_gutter).max(0.0);
482
483 let collapse_p: Vec<f32> = self.progress.iter().map(|s| s.get()).collect();
484 let mut snapshots = self.model.pane_snapshots();
489 for (i, s) in snapshots.iter_mut().enumerate() {
490 let vis = vis_p.get(i).copied().unwrap_or(1.0);
491 let prog = collapse_p.get(i).copied().unwrap_or(1.0);
492 let hiding = !s.visible || vis < 1.0 - 0.001;
499 if hiding || prog < 1.0 - 0.001 {
500 s.collapsed = true;
501 }
502 if hiding {
503 s.collapsed_size = 0.0;
504 }
505 }
506 let combined: Vec<f32> = (0..n)
507 .map(|i| {
508 collapse_p.get(i).copied().unwrap_or(1.0) * vis_p.get(i).copied().unwrap_or(1.0)
509 })
510 .collect();
511 let sizes = distribute(available, &snapshots, &combined);
512 *self.layout_sizes.borrow_mut() = sizes.clone();
513
514 let ones = vec![1.0_f32; snapshots.len()];
519 let full_sizes = distribute(available, &snapshots, &ones);
520 for (k, cell) in self.pane_full_main.iter().enumerate() {
521 cell.set(full_sizes.get(k).copied().unwrap_or(0.0));
522 }
523
524 let place = |child: &mut WidgetPlacement, local_start: f32, extent: f32| match orientation {
526 Orientation::Horizontal => {
527 let x = if rtl {
528 bounds.x + bounds.width - local_start - extent
529 } else {
530 bounds.x + local_start
531 };
532 child.origin = Point::new(x, bounds.y);
533 child.size = Size::new(extent, bounds.height);
534 }
535 Orientation::Vertical => {
536 child.origin = Point::new(bounds.x, bounds.y + local_start);
537 child.size = Size::new(bounds.width, extent);
538 }
539 };
540
541 let mut local = 0.0;
542 for k in 0..n {
543 let pane_size = sizes.get(k).copied().unwrap_or(0.0);
544 place(&mut children[2 * k], local, pane_size);
545 local += pane_size;
546 if k < n - 1 {
547 let gw = gutter_w.get(k).copied().unwrap_or(gutter);
548 place(&mut children[2 * k + 1], local, gw);
549 local += gw;
550 }
551 }
552 }
553
554 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
555 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
556 }
557
558 fn children(&self) -> Vec<WidgetId> {
559 let n = self.pane_clip_ids.len();
560 let mut v = Vec::with_capacity(2 * n);
561 for k in 0..n {
562 v.push(self.pane_clip_ids[k]);
563 if k + 1 < n
564 && let Some(h) = self.handle_ids.get(k)
565 {
566 v.push(*h);
567 }
568 }
569 v
570 }
571}
572
573#[derive(Debug)]
579struct ClipPane {
580 child_id: Option<WidgetId>,
581 labeled: bool,
582 effective_progress: Option<Signal<f32>>,
586 full_main: Rc<Cell<f32>>,
590 orientation: Orientation,
591}
592
593impl Widget for ClipPane {
594 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
595 match self.child_id {
596 Some(id) => ctx
597 .child_size(id, proposal)
598 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
599 .into(),
600 None => proposal.resolve(0.0, 0.0).into(),
601 }
602 }
603
604 fn place_children(
605 &self,
606 bounds: Rect,
607 _proposal: SizeProposal,
608 children: &mut [WidgetPlacement],
609 _ctx: &LayoutContext,
610 ) {
611 let full = self.full_main.get();
617 let size = match self.orientation {
618 Orientation::Horizontal => Size::new(full.max(bounds.width), bounds.height),
619 Orientation::Vertical => Size::new(bounds.width, full.max(bounds.height)),
620 };
621 for child in children.iter_mut() {
622 child.origin = bounds.origin();
623 child.size = size;
624 }
625 }
626
627 fn clips_children(&self) -> bool {
628 true
629 }
630
631 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
632 let collapsed = self
633 .effective_progress
634 .as_ref()
635 .map(|p| p.get() <= COLLAPSED_VISIBLE_EPSILON)
636 .unwrap_or(false);
637 if collapsed || !self.labeled {
638 builder.set_hidden();
639 } else {
640 builder.set_role(teksilo_core::accesskit::Role::Group);
641 }
642 }
643
644 fn children(&self) -> Vec<WidgetId> {
645 self.child_id.into_iter().collect()
646 }
647}