Skip to main content

teksilo_widgets/
splitter.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! N-pane split container with draggable, collapsible dividers.
5//!
6//! `Splitter` arranges `N ≥ 2` panes along one axis (per [`Orientation`])
7//! with `N − 1` grabbable handles between them — the Qt `QSplitter`
8//! model. All layout state (per-pane size / min / max / stretch /
9//! collapsed) lives in a shared, cloneable [`SplitterModel`]; the app
10//! holds a clone to read, mutate, persist, and import/export, while the
11//! widget renders it and reacts to the model's `version` signal.
12//!
13//! Strengths carried over from the old two-pane `SplitView`: anti-jump
14//! drag, keyboard resize, `Role::Splitter` accessibility, per-pane content
15//! clipping, RTL-correct horizontal layout. New: N panes, per-pane
16//! stretch (container-resize policy), animated collapse with four triggers
17//! (programmatic / double-click / drag-past-min snap / keyboard), a Tier-3
18//! [`SplitterStyle`], and serializable import/export. It is the building
19//! block `DockingLayout` is built from.
20//!
21//! Collapsing and hiding a pane each have an animated door
22//! ([`SplitterModel::set_collapsed`], [`SplitterModel::set_pane_visible`]) and
23//! an instant one ([`SplitterModel::set_collapsed_immediate`],
24//! [`SplitterModel::set_pane_visible_immediate`]) for restoring a layout.
25//!
26//! ```ignore
27//! let model = SplitterModel::from_panes(vec![
28//!     PaneDescriptor::new().size(220.0).min_size(160.0).stretch(0.0).collapsible(true),
29//!     PaneDescriptor::new().stretch(1.0).min_size(320.0),
30//!     PaneDescriptor::new().size(280.0).stretch(0.0).collapsible(true),
31//! ], Orientation::Horizontal);
32//!
33//! Splitter::new(model.clone())
34//!     .pane(sidebar).pane(editor).pane(inspector)
35//!     .pane_label(0, tr!(sidebar()));
36//! ```
37
38mod 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
66/// Below this collapse progress, a pane's content is parked dormant
67/// (and its region hidden from the a11y tree). Matches `Collapse`'s
68/// near-zero epsilon so the content stays live across the shrink
69/// animation and only drops out once it's effectively gone.
70const COLLAPSED_VISIBLE_EPSILON: f32 = 0.01;
71
72/// An N-pane resizable split container driven by a [`SplitterModel`].
73///
74/// See the [module-level documentation](self) for a usage overview and
75/// constructor patterns.
76pub struct Splitter {
77    model: SplitterModel,
78    /// Enabled state, static or reactive, gating whether the divider
79    /// handles are draggable. Forwarded to each `SplitterHandle` at
80    /// build time.
81    enabled: Prop<bool>,
82    style_override: Option<SharedSplitterStyle>,
83    /// One content slot per pane (in model order), consumed on first build.
84    pane_content: Vec<Option<PendingChild>>,
85    /// Optional accessible label per pane (locale-reactive).
86    pane_labels: Vec<Option<Prop<String>>>,
87    // ---- build-time state ----
88    pane_clip_ids: Vec<WidgetId>,
89    /// The user content widget inside each clip pane (in model order).
90    /// `visible_when`-gated on the combined collapse × visibility progress,
91    /// so collapsed or hidden content goes dormant — except a pane with a
92    /// non-zero `collapsed_size`, which keeps a visible sliver while
93    /// collapsed and is therefore gated on visibility alone.
94    pane_inner_ids: Vec<Option<WidgetId>>,
95    /// Per-pane *full* (uncollapsed) main-axis size, written each layout
96    /// pass and read by the clip so content lays out at full size and is
97    /// clipped (not reflowed) as the pane collapses.
98    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    /// Per-pane visibility tween (1 = visible, 0 = hidden). A hidden pane and
103    /// an adjacent gutter shrink to zero; content goes dormant.
104    visible_progress: Vec<Signal<f32>>,
105    prev_visible: Rc<RefCell<Vec<bool>>>,
106    /// Container bounds, shared with handles for event-time coordinate math.
107    container_bounds: Rc<Cell<Rect>>,
108    /// Layout direction, shared with handles (RTL flips the horizontal axis).
109    is_rtl: Rc<Cell<bool>>,
110    /// Effective per-pane main-axis sizes from the latest `distribute()`,
111    /// shared with handles so a drag can map pointer → boundary.
112    layout_sizes: Rc<RefCell<Vec<f32>>>,
113    /// Effective per-gap gutter widths (0 when a neighbor is hidden), shared
114    /// with handles so the drag math uses the real positions.
115    layout_gutters: Rc<RefCell<Vec<f32>>>,
116}
117
118impl Splitter {
119    /// Create a `Splitter` bound to the given model. Panes must be appended
120    /// with [`pane`](Self::pane) in model order.
121    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    /// Append a content pane (model order). Call once per pane; the count
144    /// must match `model.pane_count()`.
145    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    /// Append several content panes from an iterator, in model order.
152    ///
153    /// The loop form of [`pane`](Self::pane). The total pane count still has to
154    /// match `model.pane_count()`.
155    pub fn panes(self, iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>) -> Self {
156        iter.into_iter().fold(self, Self::pane)
157    }
158
159    /// `teksu!` ergonomic alias for [`pane`](Self::pane): a bare child in a
160    /// `Splitter { ... }` block lowers to `.child(...)`.
161    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    /// `teksu!` ergonomic alias for [`panes`](Self::panes): a `for` loop in a
169    /// `Splitter { ... }` block lowers to `.children(...)`.
170    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    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
177    ///
178    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
179    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
180    /// adds no arena node, so nothing is laid out, painted, or published to the
181    /// accessibility tree, and a stack applies no spacing around it.
182    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    /// Set an accessible region name for pane `index` (locale-reactive).
190    /// Labeled panes become a named `Role::Group`; unlabeled panes stay
191    /// AT-transparent (their content represents itself).
192    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    /// Override the active [`SplitterStyle`] for this instance only.
201    pub fn style(mut self, style: impl SplitterStyle) -> Self {
202        self.style_override = Some(Rc::new(style));
203        self
204    }
205
206    /// Enable or disable handle dragging. When `false`, divider handles are
207    /// rendered inert — the pane layout is still valid but the user cannot
208    /// resize panes. Read once at build time: a bound `Signal` is
209    /// snapshotted, not tracked.
210    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    /// Main-axis extent of `bounds` for this splitter's orientation.
227    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        // Sizes + collapse changes reflow without a rebuild.
250        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        // --- Per-pane collapse progress (1 = expanded, 0 = collapsed) ---
260        // Created first so each pane clip can gate its content's
261        // visibility on it.
262        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        // --- Per-pane visibility tween (1 = visible, 0 = hidden) --------
275        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        // --- Pane clips (each user pane clipped to its placement) -------
288        // A collapsed pane's content is parked *dormant* — excluded from
289        // paint, the focus order, hit-test, and the a11y tree — via
290        // `visible_when` on the collapse progress. A folded-away sidebar
291        // must not be Tab-focusable or announced, and its animations must
292        // pause. The gate tracks progress (not the raw collapsed flag) so
293        // the content stays live through the shrink animation and only
294        // drops out once it's effectively gone.
295        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            // Effective visibility = collapse × visible. Drives the ClipPane's
306            // shrink.
307            let effective = self.progress[i]
308                .zip(&self.visible_progress[i])
309                .map(|(c, v)| c * v);
310            // Content goes dormant when the pane is collapsed OR hidden —
311            // EXCEPT a pane with a non-zero `collapsed_size` keeps a visible
312            // sliver while collapsed (e.g. an accordion header), so it must stay
313            // live then and only drop out when truly hidden.
314            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        // --- Handles (one per gap), wired to control the adjacent panes -
341        // A gap's handle is "active" only while both its panes are visible;
342        // when a neighbor is hidden the gutter shrinks to 0 and the handle is
343        // disabled (Tab-skipped, event-gated) and AT-hidden.
344        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        // --- Effect: drive each pane's progress on collapse changes -----
369        // Animated for programmatic / double-click / keyboard triggers;
370        // snapped for drag (the pointer is already the motion). Only the
371        // panes whose collapsed flag actually changed are touched, so an
372        // unrelated drag never clobbers an in-flight collapse tween.
373        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            // Collapse changes.
383            {
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                            // Capture the pane's current *displayed* size as the
394                            // stored size, so the tween animates from where it
395                            // actually is (and restores there) — independent of
396                            // any tiny fallback stored size from a stretch-grown
397                            // pane that was never dragged.
398                            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            // Visibility changes.
414            {
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        // Query each pane's intrinsic size with an unbounded main axis.
444        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        // Each gap's gutter shrinks with the visibility of its two panes, so a
505        // hidden pane takes its adjacent gutter with it.
506        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        // A hidden pane shrinks like a collapsed one; combine both tweens. A
523        // pane mid-tween (`progress < 1`) keeps using the collapse path even
524        // after its flag flips back to expanded, so **expanding animates** too
525        // (otherwise `distribute` would jump straight to `stored_size`).
526        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            // Hidden (or mid-hide) panes fold *fully to zero* — the visibility
531            // tween removes the pane and its gutter. Collapse, by contrast,
532            // folds only to `collapsed_size` (e.g. an accordion-header sliver).
533            // So when a pane is being hidden its `collapsed_size` floor must be
534            // dropped, otherwise a pane that is both collapse-floored and hidden
535            // would stop at the sliver instead of disappearing.
536            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        // Each pane's *full* size (what it would be fully expanded). The
553        // clips lay their content at this width and clip the overflow, so
554        // content doesn't reflow as the pane collapses/hides — it's
555        // progressively revealed/hidden, the `Collapse` trick.
556        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        // Place panes + handles, walking a local main-axis cursor.
563        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/// Single-child clip wrapper for one pane. Clips overflowing content to
612/// the pane's placement so it can't bleed into a gutter or sibling pane.
613/// When `labeled`, it becomes a named `Role::Group` region (the name is
614/// supplied via the builder-level `access_label` override); otherwise it
615/// is a bare `GenericContainer` the adapters drop, and its content
616/// represents itself.
617#[derive(Debug)]
618struct ClipPane {
619    child_id: Option<WidgetId>,
620    labeled: bool,
621    /// Effective visibility (collapse × visible) of this pane, `1` = shown.
622    /// When ~0 the clip drops its `Group` role so a folded-away or hidden
623    /// labeled pane doesn't linger as an empty group.
624    effective_progress: Option<Signal<f32>>,
625    /// The pane's full (uncollapsed) main-axis size, set by the parent each
626    /// layout. The content is laid out at this size and clipped to the
627    /// (smaller, collapsing) bounds, so it doesn't reflow mid-animation.
628    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        // Lay the content at the pane's *full* main extent (never smaller
651        // than the current bounds) and let `clips_children` crop the
652        // overflow. While collapsing, bounds shrink but the content keeps
653        // its full layout — it's clipped, not reflowed. Anchored at the
654        // leading edge so it's revealed/hidden from the gutter side.
655        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        // Transparent unless it is a labelled, open pane: a bare
677        // `GenericContainer`, which every adapter drops with the content
678        // kept, even when an `access_label` gives it a name. Never
679        // `set_hidden()`, which an adapter reads as hiding the subtree: an
680        // unlabelled pane's content went with it, and so did the sliver a
681        // collapsed pane with a `collapsed_size` keeps on screen. A folded
682        // pane's content is dormant anyway, which is what keeps it out.
683        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}