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//! ```rust
26//! # use teksilo_widgets::primitives::{HStack, Shrinkable, TextWidget};
27//! # use teksilo_i18n::lit;
28//! // The label shrinks as far as 48 dp; the button stays rigid.
29//! let _row = HStack::new()
30//!     .child(Shrinkable::new().min_width(48.0)
31//!         .child(TextWidget::new(lit!("A long label that may compress")).single_line()))
32//!     .child(TextWidget::new(lit!("Rigid")));
33//! ```
34
35use teksilo_canvas::{Rect, Size, SizeProposal};
36use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
37use teksilo_core::widget_id::WidgetId;
38
39/// Layout modifier that lets its child be **compressed** when a stack is
40/// over-constrained — the shrink counterpart to [`Expand`](crate::primitives::Expand).
41///
42/// By default widgets do not shrink: when an `HStack`/`VStack` runs out of
43/// room, rigid children keep their wanted size and overflow. Wrap a child in
44/// `Shrinkable` to opt it into compression: the parent distributes any deficit
45/// across shrinkable children proportional to their shrink weight, never below
46/// the floor set here.
47///
48/// ```rust
49/// # use teksilo_widgets::primitives::{HStack, Shrinkable, TextWidget, IconWidget};
50/// # use teksilo_i18n::lit;
51/// # let long_label = TextWidget::new(lit!("A very long label that may need to shrink"));
52/// # let icon = IconWidget::chevron_right(16.0);
53/// // The label gives up space before the (rigid) icon when the row is narrow:
54/// let _w = HStack::new()
55///     .child(Shrinkable::new().min_width(40.0).child(long_label))
56///     .child(icon); // rigid — never shrinks
57/// ```
58///
59/// `Shrinkable` preserves its child's grow weight (`flex`) and cross size, so a
60/// child can both grow on surplus and shrink on a deficit. It forwards the
61/// parent's proposal to the child unchanged; when the stack compresses it, the
62/// child is re-laid-out at the smaller size (so e.g. a wrapped-text child
63/// re-wraps and reports its taller height via the height-for-width pass).
64///
65/// **Floor caveat.** The default floor is `0` on both axes, which lets the
66/// child shrink to nothing. Set [`min_width`](Self::min_width) /
67/// [`min_height`](Self::min_height) to a sensible minimum — the caller owns
68/// this choice (unlike the stock height-stable widgets, which report their own
69/// natural floor).
70#[derive(Debug)]
71pub struct Shrinkable {
72    child_id: Option<WidgetId>,
73    pending_child: Option<PendingChild>,
74    shrink: f32,
75    min_width: f32,
76    min_height: f32,
77}
78
79impl Shrinkable {
80    /// A shrinkable wrapper with shrink weight `1.0` and a zero floor.
81    pub fn new() -> Self {
82        Self {
83            child_id: None,
84            pending_child: None,
85            shrink: 1.0,
86            min_width: 0.0,
87            min_height: 0.0,
88        }
89    }
90
91    /// Set the shrink weight (relative share of an over-constraint deficit this
92    /// child absorbs). Clamped to `>= 0`; `0` makes the child rigid again.
93    pub fn shrink(mut self, weight: f32) -> Self {
94        self.shrink = weight.max(0.0);
95        self
96    }
97
98    /// Set the minimum width the child may be compressed to.
99    pub fn min_width(mut self, min: f32) -> Self {
100        self.min_width = min.max(0.0);
101        self
102    }
103
104    /// Set the minimum height the child may be compressed to.
105    pub fn min_height(mut self, min: f32) -> Self {
106        self.min_height = min.max(0.0);
107        self
108    }
109
110    /// Wrap an inline child widget (deferred insertion).
111    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
112        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
113        self
114    }
115
116    /// Wrap a pre-registered child by id.
117    pub fn child_id(mut self, id: WidgetId) -> Self {
118        self.pending_child = Some(PendingChild::Id(id));
119        self
120    }
121}
122
123impl Default for Shrinkable {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl Widget for Shrinkable {
130    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
131        if let Some(pending) = self.pending_child.take() {
132            let id = match pending {
133                PendingChild::Id(id) => id,
134                PendingChild::Deferred(w) => ctx.add_boxed(w),
135            };
136            self.child_id = Some(id);
137        }
138        self.child_id.into_iter().collect()
139    }
140
141    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
142        let Some(child) = self.child_id else {
143            return proposal.resolve(0.0, 0.0).into();
144        };
145        let r = ctx
146            .child_layout_response(child, proposal)
147            .unwrap_or(LayoutResponse::ZERO);
148        let min = Size::new(
149            self.min_width.min(r.size.width),
150            self.min_height.min(r.size.height),
151        );
152        // Preserve the child's grow weight; add this wrapper's shrink + floor.
153        LayoutResponse::flexible(r.size, r.flex)
154            .with_shrink(self.shrink)
155            .with_min(min)
156    }
157
158    fn place_children(
159        &self,
160        bounds: Rect,
161        _proposal: SizeProposal,
162        children: &mut [WidgetPlacement],
163        _ctx: &LayoutContext,
164    ) {
165        // Single child fills our (possibly compressed) bounds; the driver then
166        // re-lays it out at this exact size.
167        for child in children.iter_mut() {
168            child.origin = bounds.origin();
169            child.size = bounds.size();
170        }
171    }
172
173    fn children(&self) -> Vec<WidgetId> {
174        self.child_id.into_iter().collect()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::primitives::hstack::HStack;
182    use teksilo_core::widget_tree::WidgetTree;
183
184    #[derive(Debug)]
185    struct FixedLeaf(f32, f32);
186    impl Widget for FixedLeaf {
187        fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
188            Size::new(self.0, self.1).into()
189        }
190    }
191
192    #[test]
193    fn shrinkable_wrapper_compresses_its_child_down_to_floor() {
194        let mut tree = WidgetTree::new();
195        // 200-wide child wrapped to allow shrink to a 50 floor, plus a rigid
196        // 60 sibling. Bounds 100 → deficit 160; the wrapped child absorbs it
197        // down to its 50 floor (residual overflow), the sibling stays 60.
198        let big = tree.add(FixedLeaf(200.0, 20.0));
199        let wrapped = tree.add(Shrinkable::new().min_width(50.0).child_id(big));
200        let rigid = tree.add(FixedLeaf(60.0, 20.0));
201        let _stack = tree.add(HStack::new().add_child(wrapped).add_child(rigid));
202        tree.layout(SizeProposal::exact(100.0, 40.0));
203        assert!(
204            (tree.bounds(wrapped).width - 50.0).abs() < 0.01,
205            "wrapped width = {}",
206            tree.bounds(wrapped).width
207        );
208        assert!(
209            (tree.bounds(rigid).width - 60.0).abs() < 0.01,
210            "rigid width = {}",
211            tree.bounds(rigid).width
212        );
213        // The child fills the compressed wrapper bounds.
214        assert!((tree.bounds(big).width - 50.0).abs() < 0.01);
215    }
216
217    #[test]
218    fn shrinkable_does_not_shrink_when_there_is_room() {
219        let mut tree = WidgetTree::new();
220        let child = tree.add(FixedLeaf(80.0, 20.0));
221        let wrapped = tree.add(Shrinkable::new().min_width(20.0).child_id(child));
222        let _stack = tree.add(HStack::new().add_child(wrapped));
223        tree.layout(SizeProposal::exact(300.0, 40.0));
224        // Plenty of room → keeps its natural width (no growth, no shrink).
225        assert!((tree.bounds(wrapped).width - 80.0).abs() < 0.01);
226    }
227}