teksilo_widgets/primitives/
min_size.rs1use teksilo_canvas::{Rect, Size, SizeProposal};
52use teksilo_core::signal::Prop;
53use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
54use teksilo_core::widget_id::WidgetId;
55
56#[derive(Debug)]
60pub struct MinSize {
61 child_id: Option<WidgetId>,
62 pending_child: Option<PendingChild>,
63 min_width: Option<Prop<f32>>,
64 min_height: Option<Prop<f32>>,
65}
66
67impl MinSize {
68 pub fn new(width: f32, height: f32) -> Self {
70 Self {
71 child_id: None,
72 pending_child: None,
73 min_width: Some(Prop::Static(width)),
74 min_height: Some(Prop::Static(height)),
75 }
76 }
77
78 pub fn width(width: f32) -> Self {
80 Self {
81 child_id: None,
82 pending_child: None,
83 min_width: Some(Prop::Static(width)),
84 min_height: None,
85 }
86 }
87
88 pub fn height(height: f32) -> Self {
90 Self {
91 child_id: None,
92 pending_child: None,
93 min_width: None,
94 min_height: Some(Prop::Static(height)),
95 }
96 }
97
98 pub fn min_width(mut self, state: impl Into<Prop<f32>>) -> Self {
100 self.min_width = Some(state.into());
101 self
102 }
103
104 pub fn min_height(mut self, state: impl Into<Prop<f32>>) -> Self {
106 self.min_height = Some(state.into());
107 self
108 }
109
110 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
112 self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
113 self
114 }
115 pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
122 match widget {
123 Some(w) => self.child(w),
124 None => self,
125 }
126 }
127}
128
129impl Widget for MinSize {
130 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
131 if let Some(pending) = self.pending_child.take() {
132 self.child_id = Some(match pending {
133 PendingChild::Id(id) => id,
134 PendingChild::Deferred(w) => ctx.add_boxed(w),
135 });
136 }
137 let self_id = ctx.self_id();
138 let registry = ctx.binding_registry();
139 if let Some(ref w) = self.min_width {
140 w.register_if_bound(
141 self_id,
142 registry,
143 teksilo_core::binding::BindingLevel::Relayout,
144 );
145 }
146 if let Some(ref h) = self.min_height {
147 h.register_if_bound(
148 self_id,
149 registry,
150 teksilo_core::binding::BindingLevel::Relayout,
151 );
152 }
153 self.child_id.into_iter().collect()
154 }
155
156 fn layout_response(
157 &self,
158 proposal: SizeProposal,
159 ctx: &LayoutContext,
160 ) -> teksilo_core::widget::LayoutResponse {
161 let min_w = self.min_width.as_ref().map(|r| r.get());
162 let min_h = self.min_height.as_ref().map(|r| r.get());
163
164 let clamped_proposal = SizeProposal {
173 width: match (proposal.width, min_w) {
174 (Some(w), Some(min)) => Some(w.max(min)),
175 (w, _) => w,
176 },
177 height: match (proposal.height, min_h) {
178 (Some(h), Some(min)) => Some(h.max(min)),
179 (h, _) => h,
180 },
181 };
182
183 let child_response = self
188 .child_id
189 .and_then(|id| ctx.child_layout_response(id, clamped_proposal));
190 let child_size = child_response
191 .as_ref()
192 .map(|r| r.size)
193 .unwrap_or(Size::ZERO);
194 let child_flex = child_response.map(|r| r.flex).unwrap_or(0.0);
195 let child_shrink = child_response.map(|r| r.shrink).unwrap_or(0.0);
196 let child_min = child_response.map(|r| r.min).unwrap_or(Size::ZERO);
197
198 let w = match min_w {
199 Some(min) => child_size.width.max(min),
200 None => child_size.width,
201 };
202 let h = match min_h {
203 Some(min) => child_size.height.max(min),
204 None => child_size.height,
205 };
206
207 let floor_w = child_min.width.max(min_w.unwrap_or(0.0)).min(w);
211 let floor_h = child_min.height.max(min_h.unwrap_or(0.0)).min(h);
212 teksilo_core::widget::LayoutResponse::flexible(Size::new(w, h), child_flex)
213 .with_shrink(child_shrink)
214 .with_min(Size::new(floor_w, floor_h))
215 }
216
217 fn place_children(
218 &self,
219 bounds: Rect,
220 _proposal: SizeProposal,
221 children: &mut [WidgetPlacement],
222 _ctx: &LayoutContext,
223 ) {
224 for child in children.iter_mut() {
225 child.origin = bounds.origin();
226 child.size = bounds.size();
227 }
228 }
229
230 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
231
232 fn children(&self) -> Vec<WidgetId> {
233 self.child_id.into_iter().collect()
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use teksilo_core::signal::Signal;
241 use teksilo_core::widget_tree::WidgetTree;
242
243 #[derive(Debug)]
244 struct FixedLeaf(f32, f32);
245 impl Widget for FixedLeaf {
246 fn layout_response(
247 &self,
248 _proposal: SizeProposal,
249 _ctx: &LayoutContext,
250 ) -> teksilo_core::widget::LayoutResponse {
251 Size::new(self.0, self.1).into()
252 }
253 }
254
255 #[test]
256 fn clamps_small_child_to_minimum() {
257 let mut tree = WidgetTree::new();
258 let child = tree.add(FixedLeaf(20.0, 10.0));
259 let min = tree.add(MinSize::new(48.0, 48.0).child(child));
260 tree.layout(SizeProposal::unspecified());
261
262 let mb = tree.bounds(min);
263 assert!((mb.width - 48.0).abs() < 0.01);
264 assert!((mb.height - 48.0).abs() < 0.01);
265 }
266
267 #[test]
268 fn large_child_is_not_clamped() {
269 let mut tree = WidgetTree::new();
270 let child = tree.add(FixedLeaf(100.0, 80.0));
271 let min = tree.add(MinSize::new(48.0, 48.0).child(child));
272 tree.layout(SizeProposal::unspecified());
273
274 let mb = tree.bounds(min);
275 assert!((mb.width - 100.0).abs() < 0.01);
276 assert!((mb.height - 80.0).abs() < 0.01);
277 }
278
279 #[test]
280 fn min_width_only() {
281 let mut tree = WidgetTree::new();
282 let child = tree.add(FixedLeaf(20.0, 10.0));
283 let min = tree.add(MinSize::width(48.0).child(child));
284 tree.layout(SizeProposal::unspecified());
285
286 let mb = tree.bounds(min);
287 assert!((mb.width - 48.0).abs() < 0.01);
288 assert!((mb.height - 10.0).abs() < 0.01);
289 }
290
291 #[test]
292 fn min_width_dynamic() {
293 let min_w = Signal::new(48.0_f32);
294 let mut tree = WidgetTree::new();
295 let child = tree.add(FixedLeaf(20.0, 10.0));
296 let min = tree.add(MinSize::width(0.0).min_width(min_w.clone()).child(child));
297 tree.layout(SizeProposal::unspecified());
298 assert!((tree.bounds(min).width - 48.0).abs() < 0.01);
299
300 min_w.set(80.0);
301 tree.layout(SizeProposal::unspecified());
302 assert!((tree.bounds(min).width - 80.0).abs() < 0.01);
303 }
304
305 #[derive(Debug)]
310 struct WrappingLeaf;
311 impl Widget for WrappingLeaf {
312 fn layout_response(
313 &self,
314 proposal: SizeProposal,
315 _ctx: &LayoutContext,
316 ) -> teksilo_core::widget::LayoutResponse {
317 let content_width = 120.0_f32;
318 let line_height = 20.0_f32;
319 let w = proposal.width.unwrap_or(content_width).min(content_width);
320 let lines = (content_width / w.max(1.0)).ceil();
321 Size::new(w, lines * line_height).into()
322 }
323 }
324
325 #[test]
326 fn a_narrow_proposal_is_raised_to_the_minimum_before_the_child_measures() {
327 use crate::primitives::vstack::VStack;
331
332 let mut tree = WidgetTree::new();
333 let child = tree.add(WrappingLeaf);
334 let min = tree.add(MinSize::width(100.0).child(child));
335 let _stack = tree.add(VStack::new().child(min));
336 tree.layout(SizeProposal::exact(60.0, 200.0));
337
338 let mb = tree.bounds(min);
339 assert!(
340 (mb.width - 100.0).abs() < 0.01,
341 "width should be 100, got {}",
342 mb.width
343 );
344 assert!(
346 (mb.height - 40.0).abs() < 0.01,
347 "height should be 40 (2 lines at 100px), got {}",
348 mb.height
349 );
350 }
351
352 #[test]
353 fn an_unbounded_axis_stays_unbounded_for_the_child() {
354 let mut tree = WidgetTree::new();
358 let child = tree.add(WrappingLeaf);
359 let min = tree.add(MinSize::width(80.0).child(child));
360 tree.layout(SizeProposal::unspecified());
361
362 let mb = tree.bounds(min);
363 assert!(
364 (mb.width - 120.0).abs() < 0.01,
365 "width should be 120, got {}",
366 mb.width
367 );
368 assert!(
369 (mb.height - 20.0).abs() < 0.01,
370 "height should be 20 (one line), got {}",
371 mb.height
372 );
373 }
374
375 #[derive(Debug)]
378 struct ShrinkableLabel;
379 impl Widget for ShrinkableLabel {
380 fn layout_response(
381 &self,
382 proposal: SizeProposal,
383 _ctx: &LayoutContext,
384 ) -> teksilo_core::widget::LayoutResponse {
385 let w = proposal.width.unwrap_or(120.0).clamp(12.0, 120.0);
386 teksilo_core::widget::LayoutResponse::shrinkable(
387 Size::new(w, 20.0),
388 Size::new(12.0, 20.0),
389 1.0,
390 )
391 }
392 }
393
394 #[test]
395 fn a_shrinkable_child_keeps_its_wanted_width_on_a_stacks_main_axis() {
396 use crate::primitives::hstack::HStack;
400
401 let mut tree = WidgetTree::new();
402 let label = tree.add(ShrinkableLabel);
403 let min = tree.add(MinSize::new(24.0, 24.0).child(label));
404 let _row = tree.add(HStack::new().child(min));
405 tree.layout(SizeProposal::exact(900.0, 100.0));
406
407 let mb = tree.bounds(min);
408 assert!(
409 (mb.width - 120.0).abs() < 0.01,
410 "width should be 120, got {}",
411 mb.width
412 );
413 assert!(
414 (tree.bounds(label).width - 120.0).abs() < 0.01,
415 "the label should be 120, got {}",
416 tree.bounds(label).width
417 );
418 }
419}