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. Intended as the
19//! building block for a future `DockingLayout`.
20//!
21//! ```ignore
22//! let model = SplitterModel::from_panes(vec![
23//!     PaneDescriptor::new().size(220.0).min_size(160.0).stretch(0.0).collapsible(true),
24//!     PaneDescriptor::new().stretch(1.0).min_size(320.0),
25//!     PaneDescriptor::new().size(280.0).stretch(0.0).collapsible(true),
26//! ], Orientation::Horizontal);
27//!
28//! Splitter::new(model.clone())
29//!     .pane(sidebar).pane(editor).pane(inspector)
30//!     .pane_label(0, tr!(sidebar()));
31//! ```
32
33mod 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
61/// Below this collapse progress, a pane's content is parked dormant
62/// (and its region hidden from the a11y tree). Matches `Collapse`'s
63/// near-zero epsilon so the content stays live across the shrink
64/// animation and only drops out once it's effectively gone.
65const COLLAPSED_VISIBLE_EPSILON: f32 = 0.01;
66
67/// An N-pane resizable split container driven by a [`SplitterModel`].
68///
69/// See the [module-level documentation](self) for a usage overview and
70/// constructor patterns.
71pub struct Splitter {
72    model: SplitterModel,
73    /// Enabled state, static or reactive, gating whether the divider
74    /// handles are draggable. Forwarded to each `SplitterHandle` at
75    /// build time.
76    enabled: Prop<bool>,
77    style_override: Option<SharedSplitterStyle>,
78    /// One content slot per pane (in model order), consumed on first build.
79    pane_content: Vec<Option<PendingChild>>,
80    /// Optional accessible label per pane (locale-reactive).
81    pane_labels: Vec<Option<Prop<String>>>,
82    // ---- build-time state ----
83    pane_clip_ids: Vec<WidgetId>,
84    /// The user content widget inside each clip pane (in model order).
85    /// `visible_when`-gated on the collapse progress so collapsed content
86    /// goes dormant.
87    pane_inner_ids: Vec<Option<WidgetId>>,
88    /// Per-pane *full* (uncollapsed) main-axis size, written each layout
89    /// pass and read by the clip so content lays out at full size and is
90    /// clipped (not reflowed) as the pane collapses.
91    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    /// Per-pane visibility tween (1 = visible, 0 = hidden). A hidden pane and
96    /// an adjacent gutter shrink to zero; content goes dormant.
97    visible_progress: Vec<Signal<f32>>,
98    prev_visible: Rc<RefCell<Vec<bool>>>,
99    /// Container bounds, shared with handles for event-time coordinate math.
100    container_bounds: Rc<Cell<Rect>>,
101    /// Layout direction, shared with handles (RTL flips the horizontal axis).
102    is_rtl: Rc<Cell<bool>>,
103    /// Effective per-pane main-axis sizes from the latest `distribute()`,
104    /// shared with handles so a drag can map pointer → boundary.
105    layout_sizes: Rc<RefCell<Vec<f32>>>,
106    /// Effective per-gap gutter widths (0 when a neighbor is hidden), shared
107    /// with handles so the drag math uses the real positions.
108    layout_gutters: Rc<RefCell<Vec<f32>>>,
109}
110
111impl Splitter {
112    /// Create a `Splitter` bound to the given model. Panes must be appended
113    /// with [`pane`](Self::pane) in model order.
114    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    /// Append a content pane (model order). Call once per pane; the count
137    /// must match `model.pane_count()`.
138    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    /// Append a pre-registered content pane by id.
145    pub fn pane_id(mut self, id: WidgetId) -> Self {
146        self.pane_content.push(Some(PendingChild::Id(id)));
147        self
148    }
149
150    /// `teksu!` ergonomic alias for [`pane`](Self::pane): a bare child in a
151    /// `Splitter { ... }` block lowers to `.child(...)`.
152    pub fn child(self, widget: impl Widget + 'static) -> Self {
153        self.pane(widget)
154    }
155
156    /// Set an accessible region name for pane `index` (locale-reactive).
157    /// Labeled panes become a named `Role::Group`; unlabeled panes stay
158    /// AT-transparent (their content represents itself).
159    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    /// Override the active [`SplitterStyle`] for this instance only.
168    pub fn style(mut self, style: impl SplitterStyle) -> Self {
169        self.style_override = Some(Rc::new(style));
170        self
171    }
172
173    /// Enable or disable handle dragging, statically or reactively. When
174    /// `false`, divider handles are rendered inert — the pane layout is
175    /// still valid but the user cannot resize panes.
176    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    /// Main-axis extent of `bounds` for this splitter's orientation.
189    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        // Sizes + collapse changes reflow without a rebuild.
212        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        // --- Per-pane collapse progress (1 = expanded, 0 = collapsed) ---
222        // Created first so each pane clip can gate its content's
223        // visibility on it.
224        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        // --- Per-pane visibility tween (1 = visible, 0 = hidden) --------
237        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        // --- Pane clips (each user pane clipped to its placement) -------
250        // A collapsed pane's content is parked *dormant* — excluded from
251        // paint, the focus order, hit-test, and the a11y tree — via
252        // `visible_when` on the collapse progress. A folded-away sidebar
253        // must not be Tab-focusable or announced, and its animations must
254        // pause. The gate tracks progress (not the raw collapsed flag) so
255        // the content stays live through the shrink animation and only
256        // drops out once it's effectively gone.
257        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            // Effective visibility = collapse × visible. Drives the ClipPane's
268            // shrink.
269            let effective = self.progress[i]
270                .zip(&self.visible_progress[i])
271                .map(|(c, v)| c * v);
272            // Content goes dormant when the pane is collapsed OR hidden —
273            // EXCEPT a pane with a non-zero `collapsed_size` keeps a visible
274            // sliver while collapsed (e.g. an accordion header), so it must stay
275            // live then and only drop out when truly hidden.
276            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        // --- Handles (one per gap), wired to control the adjacent panes -
303        // A gap's handle is "active" only while both its panes are visible;
304        // when a neighbor is hidden the gutter shrinks to 0 and the handle is
305        // disabled (Tab-skipped, event-gated) and AT-hidden.
306        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        // --- Effect: drive each pane's progress on collapse changes -----
331        // Animated for programmatic / double-click / keyboard triggers;
332        // snapped for drag (the pointer is already the motion). Only the
333        // panes whose collapsed flag actually changed are touched, so an
334        // unrelated drag never clobbers an in-flight collapse tween.
335        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            // Collapse changes.
345            {
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                            // Capture the pane's current *displayed* size as the
356                            // stored size, so the tween animates from where it
357                            // actually is (and restores there) — independent of
358                            // any tiny fallback stored size from a stretch-grown
359                            // pane that was never dragged.
360                            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            // Visibility changes.
376            {
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        // Query each pane's intrinsic size with an unbounded main axis.
406        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        // Each gap's gutter shrinks with the visibility of its two panes, so a
467        // hidden pane takes its adjacent gutter with it.
468        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        // A hidden pane shrinks like a collapsed one; combine both tweens. A
485        // pane mid-tween (`progress < 1`) keeps using the collapse path even
486        // after its flag flips back to expanded, so **expanding animates** too
487        // (otherwise `distribute` would jump straight to `stored_size`).
488        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            // Hidden (or mid-hide) panes fold *fully to zero* — the visibility
493            // tween removes the pane and its gutter. Collapse, by contrast,
494            // folds only to `collapsed_size` (e.g. an accordion-header sliver).
495            // So when a pane is being hidden its `collapsed_size` floor must be
496            // dropped, otherwise a pane that is both collapse-floored and hidden
497            // would stop at the sliver instead of disappearing.
498            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        // Each pane's *full* size (what it would be fully expanded). The
515        // clips lay their content at this width and clip the overflow, so
516        // content doesn't reflow as the pane collapses/hides — it's
517        // progressively revealed/hidden, the `Collapse` trick.
518        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        // Place panes + handles, walking a local main-axis cursor.
525        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/// Single-child clip wrapper for one pane. Clips overflowing content to
574/// the pane's placement so it can't bleed into a gutter or sibling pane.
575/// When `labeled`, it becomes a named `Role::Group` region (the name is
576/// supplied via the builder-level `access_label` override); otherwise it
577/// is hidden from the AT tree (its content represents itself).
578#[derive(Debug)]
579struct ClipPane {
580    child_id: Option<WidgetId>,
581    labeled: bool,
582    /// Effective visibility (collapse × visible) of this pane, `1` = shown.
583    /// When ~0 the clip's region is hidden from the a11y tree so a
584    /// folded-away or hidden labeled pane doesn't linger as an empty group.
585    effective_progress: Option<Signal<f32>>,
586    /// The pane's full (uncollapsed) main-axis size, set by the parent each
587    /// layout. The content is laid out at this size and clipped to the
588    /// (smaller, collapsing) bounds, so it doesn't reflow mid-animation.
589    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        // Lay the content at the pane's *full* main extent (never smaller
612        // than the current bounds) and let `clips_children` crop the
613        // overflow. While collapsing, bounds shrink but the content keeps
614        // its full layout — it's clipped, not reflowed. Anchored at the
615        // leading edge so it's revealed/hidden from the gutter side.
616        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}