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}