Skip to main content

teksilo_widgets/primitives/
shrinkable.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Shrinkable — a layout modifier that allows its child to compress under an over-constraint.
5//!
6//! By default every widget is rigid: when a stack runs out of main-axis room, rigid
7//! children keep their wanted size and overflow the bounds. `Shrinkable` opts a child
8//! into the over-constraint distribution: the stack divides any deficit across all
9//! shrinkable children proportional to their [`shrink`](Shrinkable::shrink) weight,
10//! never below the [`min_width`](Shrinkable::min_width) / [`min_height`](Shrinkable::min_height)
11//! floor set here.
12//!
13//! `Shrinkable` is the shrink counterpart to
14//! [`Expand`](crate::primitives::Expand): while `Expand` claims leftover slack
15//! (grow), `Shrinkable` absorbs excess pressure (shrink). The two are independent
16//! — a child can both grow on surplus and shrink on deficit by wrapping with
17//! `Shrinkable` and setting a non-zero `flex` on the inner widget.
18//!
19//! ## When to use
20//!
21//! - A long text label that should ellipsize before a rigid icon/badge loses space.
22//! - A thumbnail image column that may compress while a fixed sidebar stays at full width.
23//! - "Compress A before B": give A `Shrinkable`, leave B rigid (`shrink = 0`).
24//!
25//! ## Ordering shrink between children
26//!
27//! The stack distributes a deficit iteratively. A child that reaches its floor is
28//! frozen at it, and its unspent share goes on to the other shrinkable children;
29//! a rigid sibling never shrinks. Weights are proportional, not strict priorities:
30//! to shrink A fully before B starts, give A a much larger weight (say 1000 times
31//! B's). B then loses only a sub-pixel amount until A reaches its floor, and takes
32//! the rest of the deficit from there.
33//!
34//! ```rust
35//! # use teksilo_widgets::primitives::{HStack, Shrinkable, TextWidget};
36//! # use teksilo_i18n::lit;
37//! // The label shrinks as far as 48 dp; the button stays rigid.
38//! let _row = HStack::new()
39//!     .child(Shrinkable::new().min_width(48.0)
40//!         .child(TextWidget::new(lit!("A long label that may compress")).single_line()))
41//!     .child(TextWidget::new(lit!("Rigid")));
42//! ```
43
44use teksilo_canvas::{Rect, Size, SizeProposal};
45use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
46use teksilo_core::widget_id::WidgetId;
47
48/// Layout modifier that lets its child be **compressed** when a stack is
49/// over-constrained — the shrink counterpart to [`Expand`](crate::primitives::Expand).
50///
51/// By default widgets do not shrink: when an `HStack`/`VStack` runs out of
52/// room, rigid children keep their wanted size and overflow. Wrap a child in
53/// `Shrinkable` to opt it into compression: the parent distributes any deficit
54/// across shrinkable children proportional to their shrink weight, never below
55/// the floor set here.
56///
57/// ```rust
58/// # use teksilo_widgets::primitives::{HStack, Shrinkable, TextWidget, IconWidget};
59/// # use teksilo_i18n::lit;
60/// # let long_label = TextWidget::new(lit!("A very long label that may need to shrink"));
61/// # let icon = IconWidget::chevron_right(16.0);
62/// // The label gives up space before the (rigid) icon when the row is narrow:
63/// let _w = HStack::new()
64///     .child(Shrinkable::new().min_width(40.0).child(long_label))
65///     .child(icon); // rigid — never shrinks
66/// ```
67///
68/// The stack distributes a deficit iteratively: a child that reaches its floor
69/// is frozen there and hands its remaining share on to the other shrinkable
70/// children, and a rigid sibling never shrinks. To shrink A fully before B
71/// starts, give A a much larger [`shrink`](Self::shrink) weight (for example
72/// 1000 times B's); B then loses only a sub-pixel amount until A is at its floor.
73///
74/// `Shrinkable` preserves its child's grow weight (`flex`) and cross size, so a
75/// child can both grow on surplus and shrink on a deficit. It forwards the
76/// parent's proposal to the child unchanged; when the stack compresses it, the
77/// child is re-laid-out at the smaller size (so e.g. a wrapped-text child
78/// re-wraps and reports its taller height via the height-for-width pass).
79///
80/// **Floor caveat.** The default floor is `0` on both axes, which lets the
81/// child shrink to nothing. Set [`min_width`](Self::min_width) /
82/// [`min_height`](Self::min_height) to a sensible minimum — the caller owns
83/// this choice (unlike the stock height-stable widgets, which report their own
84/// natural floor).
85#[derive(Debug)]
86pub struct Shrinkable {
87    child_id: Option<WidgetId>,
88    pending_child: Option<PendingChild>,
89    shrink: f32,
90    min_width: f32,
91    min_height: f32,
92}
93
94impl Shrinkable {
95    /// A shrinkable wrapper with shrink weight `1.0` and a zero floor.
96    pub fn new() -> Self {
97        Self {
98            child_id: None,
99            pending_child: None,
100            shrink: 1.0,
101            min_width: 0.0,
102            min_height: 0.0,
103        }
104    }
105
106    /// Set the shrink weight (relative share of an over-constraint deficit this
107    /// child absorbs). Clamped to `>= 0`; `0` makes the child rigid again.
108    pub fn shrink(mut self, weight: f32) -> Self {
109        self.shrink = weight.max(0.0);
110        self
111    }
112
113    /// Set the minimum width the child may be compressed to.
114    pub fn min_width(mut self, min: f32) -> Self {
115        self.min_width = min.max(0.0);
116        self
117    }
118
119    /// Set the minimum height the child may be compressed to.
120    pub fn min_height(mut self, min: f32) -> Self {
121        self.min_height = min.max(0.0);
122        self
123    }
124
125    /// Wrap an inline child widget (deferred insertion).
126    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
127        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
128        self
129    }
130    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
131    ///
132    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
133    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
134    /// adds no arena node, so nothing is laid out, painted, or published to the
135    /// accessibility tree, and a stack applies no spacing around it.
136    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
137        match widget {
138            Some(w) => self.child(w),
139            None => self,
140        }
141    }
142}
143
144impl Default for Shrinkable {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl Widget for Shrinkable {
151    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
152        if let Some(pending) = self.pending_child.take() {
153            let id = match pending {
154                PendingChild::Id(id) => id,
155                PendingChild::Deferred(w) => ctx.add_boxed(w),
156            };
157            self.child_id = Some(id);
158        }
159        self.child_id.into_iter().collect()
160    }
161
162    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
163        let Some(child) = self.child_id else {
164            return proposal.resolve(0.0, 0.0).into();
165        };
166        let r = ctx
167            .child_layout_response(child, proposal)
168            .unwrap_or(LayoutResponse::ZERO);
169        let min = Size::new(
170            self.min_width.min(r.size.width),
171            self.min_height.min(r.size.height),
172        );
173        // Preserve the child's grow weight; add this wrapper's shrink + floor.
174        LayoutResponse::flexible(r.size, r.flex)
175            .with_shrink(self.shrink)
176            .with_min(min)
177    }
178
179    fn place_children(
180        &self,
181        bounds: Rect,
182        _proposal: SizeProposal,
183        children: &mut [WidgetPlacement],
184        _ctx: &LayoutContext,
185    ) {
186        // Single child fills our (possibly compressed) bounds; the driver then
187        // re-lays it out at this exact size.
188        for child in children.iter_mut() {
189            child.origin = bounds.origin();
190            child.size = bounds.size();
191        }
192    }
193
194    fn children(&self) -> Vec<WidgetId> {
195        self.child_id.into_iter().collect()
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::primitives::hstack::HStack;
203    use teksilo_core::widget_tree::WidgetTree;
204
205    #[derive(Debug)]
206    struct FixedLeaf(f32, f32);
207    impl Widget for FixedLeaf {
208        fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
209            Size::new(self.0, self.1).into()
210        }
211    }
212
213    #[test]
214    fn shrinkable_wrapper_compresses_its_child_down_to_floor() {
215        let mut tree = WidgetTree::new();
216        // 200-wide child wrapped to allow shrink to a 50 floor, plus a rigid
217        // 60 sibling. Bounds 100 → deficit 160; the wrapped child absorbs it
218        // down to its 50 floor (residual overflow), the sibling stays 60.
219        let big = tree.add(FixedLeaf(200.0, 20.0));
220        let wrapped = tree.add(Shrinkable::new().min_width(50.0).child(big));
221        let rigid = tree.add(FixedLeaf(60.0, 20.0));
222        let _stack = tree.add(HStack::new().child(wrapped).child(rigid));
223        tree.layout(SizeProposal::exact(100.0, 40.0));
224        assert!(
225            (tree.bounds(wrapped).width - 50.0).abs() < 0.01,
226            "wrapped width = {}",
227            tree.bounds(wrapped).width
228        );
229        assert!(
230            (tree.bounds(rigid).width - 60.0).abs() < 0.01,
231            "rigid width = {}",
232            tree.bounds(rigid).width
233        );
234        // The child fills the compressed wrapper bounds.
235        assert!((tree.bounds(big).width - 50.0).abs() < 0.01);
236    }
237
238    #[test]
239    fn shrinkable_does_not_shrink_when_there_is_room() {
240        let mut tree = WidgetTree::new();
241        let child = tree.add(FixedLeaf(80.0, 20.0));
242        let wrapped = tree.add(Shrinkable::new().min_width(20.0).child(child));
243        let _stack = tree.add(HStack::new().child(wrapped));
244        tree.layout(SizeProposal::exact(300.0, 40.0));
245        // Plenty of room → keeps its natural width (no growth, no shrink).
246        assert!((tree.bounds(wrapped).width - 80.0).abs() < 0.01);
247    }
248}