1use teksilo_canvas::{Point, Rect, Size, SizeProposal};
29use teksilo_core::accessibility::AccessNodeBuilder;
30use teksilo_core::signal::Prop;
31use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
32use teksilo_core::widget_id::WidgetId;
33use teksilo_tokens::HAlignment;
34
35use crate::primitives::linear_layout::{self, Axis};
36
37#[derive(Debug)]
41pub struct VStack {
42 child_ids: Vec<WidgetId>,
43 pending: Vec<PendingChild>,
44 spacing: Prop<f32>,
45 alignment: HAlignment,
46}
47
48impl VStack {
49 pub fn new() -> Self {
51 Self {
52 child_ids: Vec::new(),
53 pending: Vec::new(),
54 spacing: Prop::Static(0.0),
55 alignment: HAlignment::Leading,
56 }
57 }
58
59 pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
62 self.spacing = spacing.into();
63 self
64 }
65
66 pub fn alignment(mut self, alignment: HAlignment) -> Self {
69 self.alignment = alignment;
70 self
71 }
72
73 pub fn add_child(mut self, id: WidgetId) -> Self {
75 self.pending.push(PendingChild::Id(id));
76 self
77 }
78
79 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
81 self.pending.push(PendingChild::Deferred(Box::new(widget)));
82 self
83 }
84
85 pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
87 for widget in iter {
88 self.pending.push(PendingChild::Deferred(Box::new(widget)));
89 }
90 self
91 }
92
93 pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
95 if let Some(w) = widget {
96 self.pending.push(PendingChild::Deferred(Box::new(w)));
97 }
98 self
99 }
100}
101
102impl Default for VStack {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl Widget for VStack {
109 fn layout_response(
110 &self,
111 proposal: SizeProposal,
112 ctx: &LayoutContext,
113 ) -> teksilo_core::widget::LayoutResponse {
114 if self.child_ids.is_empty() {
115 return proposal.resolve(0.0, 0.0).into();
116 }
117 let neg = linear_layout::negotiate(
121 &self.child_ids,
122 ctx,
123 proposal.height,
124 proposal.width,
125 self.spacing.get(),
126 Axis::Vertical,
127 );
128 linear_layout::response(&neg)
129 }
130
131 fn place_children(
132 &self,
133 bounds: Rect,
134 _proposal: SizeProposal,
135 children: &mut [WidgetPlacement],
136 ctx: &LayoutContext,
137 ) {
138 if children.is_empty() {
139 return;
140 }
141
142 let ids: Vec<WidgetId> = children.iter().map(|c| c.id).collect();
143 let neg = linear_layout::negotiate(
144 &ids,
145 ctx,
146 Some(bounds.height),
147 Some(bounds.width),
148 self.spacing.get(),
149 Axis::Vertical,
150 );
151 let heights = &neg.children.main;
153 let widths = &neg.children.cross;
154
155 let spacing = self.spacing.get();
157 let rtl = ctx.is_rtl();
158 let mut y = bounds.y;
159 for (i, child) in children.iter_mut().enumerate() {
160 let w = widths[i];
161 let h = heights[i];
162
163 let halign = ctx
164 .child_alignment(child.id)
165 .map(|a| a.horizontal)
166 .unwrap_or(self.alignment);
167 let x_offset = halign.resolve(w, bounds.width, rtl);
168
169 child.origin = Point::new(bounds.x + x_offset, y);
170 child.size = Size::new(w, h);
171 y += h + spacing;
172 }
173 }
174
175 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
176
177 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
178 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
179 }
180
181 fn children(&self) -> Vec<WidgetId> {
182 self.child_ids.clone()
183 }
184
185 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
186 let pending = std::mem::take(&mut self.pending);
187 if !pending.is_empty() {
188 self.child_ids = pending
189 .into_iter()
190 .map(|child| match child {
191 PendingChild::Id(id) => id,
192 PendingChild::Deferred(w) => ctx.add_boxed(w),
193 })
194 .collect();
195 }
196 let self_id = ctx.self_id();
197 let registry = ctx.binding_registry();
198 self.spacing.register_if_bound(
199 self_id,
200 registry,
201 teksilo_core::binding::BindingLevel::Relayout,
202 );
203 self.child_ids.clone()
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use teksilo_core::widget_tree::WidgetTree;
211
212 #[derive(Debug)]
214 struct FixedLeaf(f32, f32);
215 impl Widget for FixedLeaf {
216 fn layout_response(
217 &self,
218 _proposal: SizeProposal,
219 _ctx: &LayoutContext,
220 ) -> teksilo_core::widget::LayoutResponse {
221 Size::new(self.0, self.1).into()
222 }
223 }
224
225 #[test]
226 fn children_get_intrinsic_heights() {
227 let mut tree = WidgetTree::new();
228 let a = tree.add(FixedLeaf(80.0, 30.0));
229 let b = tree.add(FixedLeaf(60.0, 50.0));
230 let _stack = tree.add(VStack::new().add_child(a).add_child(b));
231 tree.layout(SizeProposal::exact(200.0, 300.0));
232
233 assert!((tree.bounds(a).height - 30.0).abs() < 0.01);
234 assert!((tree.bounds(b).height - 50.0).abs() < 0.01);
235 assert!((tree.bounds(b).y - 30.0).abs() < 0.01);
236 }
237
238 #[test]
239 fn nested_vstack_with_content_carrying_expand_reports_full_height() {
240 use crate::primitives::expand::Expand;
249
250 let mut tree = WidgetTree::new();
251 let tab_bar = tree.add(FixedLeaf(120.0, 32.0));
252 let content = tree.add(FixedLeaf(120.0, 200.0));
253 let filled = tree.add(Expand::vertical().respect_intrinsic().child_id(content));
254 let inner = tree.add(VStack::new().add_child(tab_bar).add_child(filled));
255
256 let sibling = tree.add(FixedLeaf(120.0, 40.0));
259 let outer = tree.add(VStack::new().add_child(inner).add_child(sibling));
260 tree.layout(SizeProposal {
261 width: Some(400.0),
262 height: None,
263 });
264
265 let inner_bounds = tree.bounds(inner);
267 assert!(
268 (inner_bounds.height - 232.0).abs() < 0.01,
269 "inner VStack height should include the content-carrying Expand, got {}",
270 inner_bounds.height,
271 );
272
273 let sibling_bounds = tree.bounds(sibling);
276 assert!(
277 sibling_bounds.y >= inner_bounds.bottom() - 0.01,
278 "sibling should be placed below the inner stack; \
279 inner bottom {}, sibling y {}",
280 inner_bounds.bottom(),
281 sibling_bounds.y,
282 );
283
284 assert!((tree.bounds(tab_bar).y - inner_bounds.y).abs() < 0.01);
287 let filled_bounds = tree.bounds(filled);
288 assert!(filled_bounds.y >= inner_bounds.y + 32.0 - 0.01);
289
290 let outer_bounds = tree.bounds(outer);
292 assert!(
293 (outer_bounds.height - 272.0).abs() < 0.01,
294 "outer VStack height got {}, expected 272",
295 outer_bounds.height,
296 );
297 }
298
299 #[test]
300 fn spacing_between_children() {
301 let mut tree = WidgetTree::new();
302 let a = tree.add(FixedLeaf(80.0, 40.0));
303 let b = tree.add(FixedLeaf(80.0, 40.0));
304 let _stack = tree.add(VStack::new().spacing(10.0).add_child(a).add_child(b));
305 tree.layout(SizeProposal::exact(200.0, 300.0));
306
307 assert!((tree.bounds(b).y - 50.0).abs() < 0.01); }
309
310 #[test]
311 fn horizontal_flex_does_not_leak_into_vertical_growth() {
312 use crate::primitives::hstack::HStack;
316 use crate::primitives::spacer::Spacer;
317
318 let mut tree = WidgetTree::new();
319 let row = tree.add(
320 HStack::new()
321 .child(FixedLeaf(40.0, 30.0))
322 .child(Spacer::new())
323 .child(FixedLeaf(40.0, 30.0)),
324 );
325 let _col = tree.add(VStack::new().add_child(row));
326 tree.layout(SizeProposal::exact(400.0, 500.0)); assert!(
330 (tree.bounds(row).height - 30.0).abs() < 0.01,
331 "row should stay at content height 30, got {}",
332 tree.bounds(row).height
333 );
334 }
335
336 #[test]
337 fn cross_axis_leading_alignment_ltr() {
338 let mut tree = WidgetTree::new();
339 let a = tree.add(FixedLeaf(80.0, 30.0));
340 let _stack = tree.add(VStack::new().add_child(a)); tree.layout(SizeProposal::exact(200.0, 300.0));
342
343 assert!((tree.bounds(a).x - 0.0).abs() < 0.01); }
345
346 #[test]
347 fn cross_axis_center_alignment() {
348 let mut tree = WidgetTree::new();
349 let a = tree.add(FixedLeaf(80.0, 30.0));
350 let _stack = tree.add(VStack::new().alignment(HAlignment::Center).add_child(a));
351 tree.layout(SizeProposal::exact(200.0, 300.0));
352
353 assert!((tree.bounds(a).x - 60.0).abs() < 0.01); }
355
356 #[test]
357 fn cross_axis_trailing_alignment() {
358 let mut tree = WidgetTree::new();
359 let a = tree.add(FixedLeaf(80.0, 30.0));
360 let _stack = tree.add(VStack::new().alignment(HAlignment::Trailing).add_child(a));
361 tree.layout(SizeProposal::exact(200.0, 300.0));
362
363 assert!((tree.bounds(a).x - 120.0).abs() < 0.01); }
365
366 #[test]
367 fn per_child_alignment_override() {
368 let mut tree = WidgetTree::new();
369 let a = tree.add(FixedLeaf(80.0, 30.0));
370 let b = tree.add(FixedLeaf(60.0, 30.0));
371 let _stack = tree.add(VStack::new().add_child(a).add_child(b)); tree.set_alignment(
373 b,
374 teksilo_tokens::Alignment {
375 horizontal: teksilo_tokens::HAlignment::Trailing,
376 vertical: teksilo_tokens::VAlignment::Center,
377 },
378 );
379 tree.layout(SizeProposal::exact(200.0, 300.0));
380
381 assert!((tree.bounds(a).x - 0.0).abs() < 0.01); assert!((tree.bounds(b).x - 140.0).abs() < 0.01); }
384
385 #[test]
386 fn empty_vstack() {
387 let mut tree = WidgetTree::new();
388 let _stack = tree.add(VStack::new());
389 tree.layout(SizeProposal::exact(200.0, 50.0));
390 }
391
392 #[test]
395 fn child_inline_resolves_layout() {
396 let mut tree = WidgetTree::new();
397 let stack = tree.add(
398 VStack::new()
399 .child(FixedLeaf(80.0, 30.0))
400 .child(FixedLeaf(60.0, 50.0)),
401 );
402 tree.layout(SizeProposal::exact(200.0, 300.0));
403
404 let kids = tree.children(stack);
405 assert_eq!(kids.len(), 2);
406 assert!((tree.bounds(kids[0]).height - 30.0).abs() < 0.01);
407 assert!((tree.bounds(kids[1]).height - 50.0).abs() < 0.01);
408 assert!((tree.bounds(kids[1]).y - 30.0).abs() < 0.01);
409 }
410
411 #[test]
412 fn mixed_add_child_and_inline_child() {
413 let mut tree = WidgetTree::new();
414 let pre = tree.add(FixedLeaf(80.0, 20.0));
415 let stack = tree.add(VStack::new().add_child(pre).child(FixedLeaf(80.0, 40.0)));
416 tree.layout(SizeProposal::exact(200.0, 300.0));
417
418 let kids = tree.children(stack);
419 assert_eq!(kids.len(), 2);
420 assert_eq!(kids[0], pre);
421 assert!((tree.bounds(kids[0]).height - 20.0).abs() < 0.01);
422 assert!((tree.bounds(kids[1]).height - 40.0).abs() < 0.01);
423 assert!((tree.bounds(kids[1]).y - 20.0).abs() < 0.01);
424 }
425
426 #[test]
427 fn children_iterator() {
428 let leaves: Vec<FixedLeaf> = vec![
429 FixedLeaf(80.0, 10.0),
430 FixedLeaf(80.0, 20.0),
431 FixedLeaf(80.0, 30.0),
432 ];
433 let mut tree = WidgetTree::new();
434 let stack = tree.add(VStack::new().children(leaves));
435 tree.layout(SizeProposal::exact(200.0, 300.0));
436
437 let kids = tree.children(stack);
438 assert_eq!(kids.len(), 3);
439 assert!((tree.bounds(kids[2]).y - 30.0).abs() < 0.01); }
441
442 #[test]
443 fn child_opt_none_is_noop() {
444 let mut tree = WidgetTree::new();
445 let stack = tree.add(
446 VStack::new()
447 .child(FixedLeaf(80.0, 30.0))
448 .child_opt(None::<FixedLeaf>)
449 .child(FixedLeaf(80.0, 50.0)),
450 );
451 tree.layout(SizeProposal::exact(200.0, 300.0));
452
453 let kids = tree.children(stack);
454 assert_eq!(kids.len(), 2);
455 }
456
457 #[test]
458 fn child_opt_some_adds_child() {
459 let mut tree = WidgetTree::new();
460 let stack = tree.add(VStack::new().child_opt(Some(FixedLeaf(80.0, 25.0))));
461 tree.layout(SizeProposal::exact(200.0, 300.0));
462
463 let kids = tree.children(stack);
464 assert_eq!(kids.len(), 1);
465 assert!((tree.bounds(kids[0]).height - 25.0).abs() < 0.01);
466 }
467
468 #[test]
469 fn nested_inline_children() {
470 use crate::primitives::hstack::HStack;
471
472 let mut tree = WidgetTree::new();
473 let outer = tree.add(
474 VStack::new()
475 .child(
476 HStack::new()
477 .child(FixedLeaf(40.0, 30.0))
478 .child(FixedLeaf(50.0, 30.0)),
479 )
480 .child(FixedLeaf(80.0, 20.0)),
481 );
482 tree.layout(SizeProposal::exact(200.0, 300.0));
483
484 let outer_kids = tree.children(outer);
485 assert_eq!(outer_kids.len(), 2);
486 let hstack_kids = tree.children(outer_kids[0]);
488 assert_eq!(hstack_kids.len(), 2);
489 assert!((tree.bounds(outer_kids[1]).y - 30.0).abs() < 0.01);
491 }
492
493 #[test]
494 fn single_child_wrapper_inline() {
495 use crate::primitives::padding::Padding;
496
497 let mut tree = WidgetTree::new();
498 let stack =
499 tree.add(VStack::new().child(Padding::uniform(10.0).child(FixedLeaf(80.0, 30.0))));
500 tree.layout(SizeProposal::exact(200.0, 300.0));
501
502 let kids = tree.children(stack);
503 assert_eq!(kids.len(), 1);
504 assert!((tree.bounds(kids[0]).height - 50.0).abs() < 0.01);
506 }
507
508 #[test]
509 fn dormant_child_does_not_take_layout_space() {
510 let mut tree = WidgetTree::new();
511 let a = tree.add(FixedLeaf(80.0, 30.0));
512 let b = tree.add(FixedLeaf(80.0, 40.0));
513 let c = tree.add(FixedLeaf(80.0, 50.0));
514 let _stack = tree.add(
515 VStack::new()
516 .spacing(10.0)
517 .add_child(a)
518 .add_child(b)
519 .add_child(c),
520 );
521 tree.layout(SizeProposal::exact(200.0, 300.0));
522
523 assert!((tree.bounds(c).y - 90.0).abs() < 0.01);
525
526 tree.set_dormant(b);
528 tree.layout(SizeProposal::exact(200.0, 300.0));
529
530 assert!((tree.bounds(c).y - 40.0).abs() < 0.01);
532 }
533
534 #[test]
535 fn dormant_child_via_visible_when_does_not_take_layout_space() {
536 use teksilo_core::signal::Signal;
537
538 let show_b = Signal::new(true);
539 let mut tree = WidgetTree::new();
540 let a = tree.add(FixedLeaf(80.0, 30.0));
541 let b = tree.add(FixedLeaf(80.0, 40.0));
542 tree.visible_when(b, show_b.clone());
543 let c = tree.add(FixedLeaf(80.0, 50.0));
544 let _stack = tree.add(
545 VStack::new()
546 .spacing(10.0)
547 .add_child(a)
548 .add_child(b)
549 .add_child(c),
550 );
551 tree.layout(SizeProposal::exact(200.0, 300.0));
552
553 assert!((tree.bounds(c).y - 90.0).abs() < 0.01);
555
556 show_b.set(false);
558 tree.layout(SizeProposal::exact(200.0, 300.0));
559
560 assert!((tree.bounds(c).y - 40.0).abs() < 0.01);
562
563 show_b.set(true);
565 tree.layout(SizeProposal::exact(200.0, 300.0));
566
567 assert!((tree.bounds(c).y - 90.0).abs() < 0.01);
569 }
570
571 #[test]
575 fn cross_axis_still_fills_offered_width_when_content_fits() {
576 let mut tree = WidgetTree::new();
577 let a = tree.add(FixedLeaf(100.0, 40.0));
578 let b = tree.add(FixedLeaf(100.0, 40.0));
579 let row = tree.add(crate::primitives::HStack::new().add_child(a).add_child(b));
580 let stack = tree.add(VStack::new().add_child(row));
581
582 tree.layout(SizeProposal::exact(560.0, 400.0));
584
585 assert!(
586 (tree.bounds(stack).width - 560.0).abs() < 0.01,
587 "fitting content must still fill the offered 560 dp, not collapse \
588 to its natural 200: got {}",
589 tree.bounds(stack).width
590 );
591 }
592}