teksilo_widgets/primitives/
min_size.rs1use teksilo_canvas::{Rect, Size, SizeProposal};
27use teksilo_core::signal::Prop;
28use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30
31#[derive(Debug)]
35pub struct MinSize {
36 child_id: Option<WidgetId>,
37 pending_child: Option<PendingChild>,
38 min_width: Option<Prop<f32>>,
39 min_height: Option<Prop<f32>>,
40}
41
42impl MinSize {
43 pub fn new(width: f32, height: f32) -> Self {
45 Self {
46 child_id: None,
47 pending_child: None,
48 min_width: Some(Prop::Static(width)),
49 min_height: Some(Prop::Static(height)),
50 }
51 }
52
53 pub fn width(width: f32) -> Self {
55 Self {
56 child_id: None,
57 pending_child: None,
58 min_width: Some(Prop::Static(width)),
59 min_height: None,
60 }
61 }
62
63 pub fn height(height: f32) -> Self {
65 Self {
66 child_id: None,
67 pending_child: None,
68 min_width: None,
69 min_height: Some(Prop::Static(height)),
70 }
71 }
72
73 pub fn min_width(mut self, state: impl Into<Prop<f32>>) -> Self {
75 self.min_width = Some(state.into());
76 self
77 }
78
79 pub fn min_height(mut self, state: impl Into<Prop<f32>>) -> Self {
81 self.min_height = Some(state.into());
82 self
83 }
84
85 pub fn child_id(mut self, id: WidgetId) -> Self {
87 self.pending_child = Some(PendingChild::Id(id));
88 self
89 }
90
91 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
93 self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
94 self
95 }
96}
97
98impl Widget for MinSize {
99 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
100 if let Some(pending) = self.pending_child.take() {
101 self.child_id = Some(match pending {
102 PendingChild::Id(id) => id,
103 PendingChild::Deferred(w) => ctx.add_boxed(w),
104 });
105 }
106 let self_id = ctx.self_id();
107 let registry = ctx.binding_registry();
108 if let Some(ref w) = self.min_width {
109 w.register_if_bound(
110 self_id,
111 registry,
112 teksilo_core::binding::BindingLevel::Relayout,
113 );
114 }
115 if let Some(ref h) = self.min_height {
116 h.register_if_bound(
117 self_id,
118 registry,
119 teksilo_core::binding::BindingLevel::Relayout,
120 );
121 }
122 self.child_id.into_iter().collect()
123 }
124
125 fn layout_response(
126 &self,
127 proposal: SizeProposal,
128 ctx: &LayoutContext,
129 ) -> teksilo_core::widget::LayoutResponse {
130 let min_w = self.min_width.as_ref().map(|r| r.get());
131 let min_h = self.min_height.as_ref().map(|r| r.get());
132
133 let clamped_proposal = SizeProposal {
138 width: match (proposal.width, min_w) {
139 (Some(w), Some(min)) => Some(w.max(min)),
140 (None, Some(min)) => Some(min),
141 (w, None) => w,
142 },
143 height: match (proposal.height, min_h) {
144 (Some(h), Some(min)) => Some(h.max(min)),
145 (None, Some(min)) => Some(min),
146 (h, None) => h,
147 },
148 };
149
150 let child_response = self
155 .child_id
156 .and_then(|id| ctx.child_layout_response(id, clamped_proposal));
157 let child_size = child_response
158 .as_ref()
159 .map(|r| r.size)
160 .unwrap_or(Size::ZERO);
161 let child_flex = child_response.map(|r| r.flex).unwrap_or(0.0);
162 let child_shrink = child_response.map(|r| r.shrink).unwrap_or(0.0);
163 let child_min = child_response.map(|r| r.min).unwrap_or(Size::ZERO);
164
165 let w = match min_w {
166 Some(min) => child_size.width.max(min),
167 None => child_size.width,
168 };
169 let h = match min_h {
170 Some(min) => child_size.height.max(min),
171 None => child_size.height,
172 };
173
174 let floor_w = child_min.width.max(min_w.unwrap_or(0.0)).min(w);
178 let floor_h = child_min.height.max(min_h.unwrap_or(0.0)).min(h);
179 teksilo_core::widget::LayoutResponse::flexible(Size::new(w, h), child_flex)
180 .with_shrink(child_shrink)
181 .with_min(Size::new(floor_w, floor_h))
182 }
183
184 fn place_children(
185 &self,
186 bounds: Rect,
187 _proposal: SizeProposal,
188 children: &mut [WidgetPlacement],
189 _ctx: &LayoutContext,
190 ) {
191 for child in children.iter_mut() {
192 child.origin = bounds.origin();
193 child.size = bounds.size();
194 }
195 }
196
197 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
198
199 fn children(&self) -> Vec<WidgetId> {
200 self.child_id.into_iter().collect()
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use teksilo_core::signal::Signal;
208 use teksilo_core::widget_tree::WidgetTree;
209
210 #[derive(Debug)]
211 struct FixedLeaf(f32, f32);
212 impl Widget for FixedLeaf {
213 fn layout_response(
214 &self,
215 _proposal: SizeProposal,
216 _ctx: &LayoutContext,
217 ) -> teksilo_core::widget::LayoutResponse {
218 Size::new(self.0, self.1).into()
219 }
220 }
221
222 #[test]
223 fn clamps_small_child_to_minimum() {
224 let mut tree = WidgetTree::new();
225 let child = tree.add(FixedLeaf(20.0, 10.0));
226 let min = tree.add(MinSize::new(48.0, 48.0).child_id(child));
227 tree.layout(SizeProposal::unspecified());
228
229 let mb = tree.bounds(min);
230 assert!((mb.width - 48.0).abs() < 0.01);
231 assert!((mb.height - 48.0).abs() < 0.01);
232 }
233
234 #[test]
235 fn large_child_is_not_clamped() {
236 let mut tree = WidgetTree::new();
237 let child = tree.add(FixedLeaf(100.0, 80.0));
238 let min = tree.add(MinSize::new(48.0, 48.0).child_id(child));
239 tree.layout(SizeProposal::unspecified());
240
241 let mb = tree.bounds(min);
242 assert!((mb.width - 100.0).abs() < 0.01);
243 assert!((mb.height - 80.0).abs() < 0.01);
244 }
245
246 #[test]
247 fn min_width_only() {
248 let mut tree = WidgetTree::new();
249 let child = tree.add(FixedLeaf(20.0, 10.0));
250 let min = tree.add(MinSize::width(48.0).child_id(child));
251 tree.layout(SizeProposal::unspecified());
252
253 let mb = tree.bounds(min);
254 assert!((mb.width - 48.0).abs() < 0.01);
255 assert!((mb.height - 10.0).abs() < 0.01);
256 }
257
258 #[test]
259 fn min_width_dynamic() {
260 let min_w = Signal::new(48.0_f32);
261 let mut tree = WidgetTree::new();
262 let child = tree.add(FixedLeaf(20.0, 10.0));
263 let min = tree.add(MinSize::width(0.0).min_width(min_w.clone()).child_id(child));
264 tree.layout(SizeProposal::unspecified());
265 assert!((tree.bounds(min).width - 48.0).abs() < 0.01);
266
267 min_w.set(80.0);
268 tree.layout(SizeProposal::unspecified());
269 assert!((tree.bounds(min).width - 80.0).abs() < 0.01);
270 }
271
272 #[derive(Debug)]
277 struct WrappingLeaf;
278 impl Widget for WrappingLeaf {
279 fn layout_response(
280 &self,
281 proposal: SizeProposal,
282 _ctx: &LayoutContext,
283 ) -> teksilo_core::widget::LayoutResponse {
284 let content_width = 120.0_f32;
285 let line_height = 20.0_f32;
286 let w = proposal.width.unwrap_or(content_width).min(content_width);
287 let lines = (content_width / w.max(1.0)).ceil();
288 Size::new(w, lines * line_height).into()
289 }
290 }
291
292 #[test]
293 fn child_receives_clamped_proposal() {
294 use crate::primitives::vstack::VStack;
299
300 let mut tree = WidgetTree::new();
301 let child = tree.add(WrappingLeaf);
302 let min = tree.add(MinSize::width(100.0).child_id(child));
303 let _stack = tree.add(VStack::new().add_child(min));
304 tree.layout(SizeProposal {
305 width: None,
306 height: None,
307 });
308
309 let mb = tree.bounds(min);
310 assert!(
311 (mb.width - 100.0).abs() < 0.01,
312 "width should be 100, got {}",
313 mb.width
314 );
315 assert!(
317 (mb.height - 40.0).abs() < 0.01,
318 "height should be 40 (2 lines at 100px), got {}",
319 mb.height
320 );
321 }
322
323 #[test]
324 fn unspecified_proposal_gets_clamped_to_minimum() {
325 let mut tree = WidgetTree::new();
329 let child = tree.add(WrappingLeaf);
330 let min = tree.add(MinSize::width(80.0).child_id(child));
331 tree.layout(SizeProposal::unspecified());
332
333 let mb = tree.bounds(min);
334 assert!(
335 (mb.width - 80.0).abs() < 0.01,
336 "width should be 80, got {}",
337 mb.width
338 );
339 assert!(
341 (mb.height - 40.0).abs() < 0.01,
342 "height should be 40 (2 lines at 80px), got {}",
343 mb.height
344 );
345 }
346}