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}