1use teksilo_canvas::{Point, Rect, Size, SizeProposal};
26use teksilo_core::accessibility::AccessNodeBuilder;
27use teksilo_core::signal::Prop;
28use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30use teksilo_tokens::VAlignment;
31
32use crate::primitives::linear_layout::{self, Axis};
33
34#[derive(Debug)]
40pub struct HStack {
41 child_ids: Vec<WidgetId>,
42 pending: Vec<PendingChild>,
43 spacing: Prop<f32>,
44 alignment: VAlignment,
45}
46
47impl HStack {
48 pub fn new() -> Self {
50 Self {
51 child_ids: Vec::new(),
52 pending: Vec::new(),
53 spacing: Prop::Static(0.0),
54 alignment: VAlignment::Center,
55 }
56 }
57
58 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: VAlignment) -> Self {
68 self.alignment = alignment;
69 self
70 }
71
72 pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
74 self.pending
75 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
76 self
77 }
78
79 pub fn children(
81 mut self,
82 iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
83 ) -> Self {
84 for widget in iter {
85 self.pending
86 .push(teksilo_core::IntoTeksiChild::into_pending(widget));
87 }
88 self
89 }
90
91 pub fn child_opt(mut self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
93 if let Some(w) = widget {
94 self.pending
95 .push(teksilo_core::IntoTeksiChild::into_pending(w));
96 }
97 self
98 }
99}
100
101impl Default for HStack {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl Widget for HStack {
108 fn layout_response(
109 &self,
110 proposal: SizeProposal,
111 ctx: &LayoutContext,
112 ) -> teksilo_core::widget::LayoutResponse {
113 if self.child_ids.is_empty() {
114 return proposal.resolve(0.0, 0.0).into();
115 }
116 let neg = linear_layout::negotiate(
121 &self.child_ids,
122 ctx,
123 proposal.width,
124 proposal.height,
125 self.spacing.get(),
126 Axis::Horizontal,
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.width),
147 Some(bounds.height),
148 self.spacing.get(),
149 Axis::Horizontal,
150 );
151 let widths = &neg.children.main;
152 let heights = &neg.children.cross;
153
154 let spacing = self.spacing.get();
157 let rtl = ctx.is_rtl();
158 if rtl {
159 let mut x = bounds.right();
160 for (i, child) in children.iter_mut().enumerate() {
161 let w = widths[i];
162 let h = heights[i];
163 let valign = ctx
164 .child_alignment(child.id)
165 .map(|a| a.vertical)
166 .unwrap_or(self.alignment);
167 let y_offset = valign.resolve(h, bounds.height);
168
169 x -= w;
170 child.origin = Point::new(x, bounds.y + y_offset);
171 child.size = Size::new(w, h);
172 x -= spacing;
173 }
174 } else {
175 let mut x = bounds.x;
176 for (i, child) in children.iter_mut().enumerate() {
177 let w = widths[i];
178 let h = heights[i];
179 let valign = ctx
180 .child_alignment(child.id)
181 .map(|a| a.vertical)
182 .unwrap_or(self.alignment);
183 let y_offset = valign.resolve(h, bounds.height);
184
185 child.origin = Point::new(x, bounds.y + y_offset);
186 child.size = Size::new(w, h);
187 x += w + spacing;
188 }
189 }
190 }
191
192 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
193
194 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
195 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
196 }
197
198 fn children(&self) -> Vec<WidgetId> {
199 self.child_ids.clone()
200 }
201
202 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
203 let pending = std::mem::take(&mut self.pending);
204 if !pending.is_empty() {
205 self.child_ids = pending
206 .into_iter()
207 .map(|child| match child {
208 PendingChild::Id(id) => id,
209 PendingChild::Deferred(w) => ctx.add_boxed(w),
210 })
211 .collect();
212 }
213 let self_id = ctx.self_id();
216 let registry = ctx.binding_registry();
217 self.spacing.register_if_bound(
218 self_id,
219 registry,
220 teksilo_core::binding::BindingLevel::Relayout,
221 );
222 self.child_ids.clone()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use teksilo_core::widget_tree::WidgetTree;
230
231 #[derive(Debug)]
233 struct FixedLeaf(f32, f32);
234 impl Widget for FixedLeaf {
235 fn layout_response(
236 &self,
237 _proposal: SizeProposal,
238 _ctx: &LayoutContext,
239 ) -> teksilo_core::widget::LayoutResponse {
240 Size::new(self.0, self.1).into()
241 }
242 }
243
244 use teksilo_core::widget::LayoutResponse;
245
246 #[derive(Debug)]
248 struct ShrinkLeaf {
249 wanted: f32,
250 min: f32,
251 shrink: f32,
252 height: f32,
253 }
254 impl Widget for ShrinkLeaf {
255 fn layout_response(&self, _p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
256 LayoutResponse::shrinkable(
257 Size::new(self.wanted, self.height),
258 Size::new(self.min, self.height),
259 self.shrink,
260 )
261 }
262 }
263
264 #[derive(Debug)]
268 struct AreaLeaf {
269 area: f32,
270 wanted_w: f32,
271 min_w: f32,
272 }
273 impl Widget for AreaLeaf {
274 fn layout_response(&self, p: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
275 let w = p.width.unwrap_or(self.wanted_w);
276 let h = self.area / w.max(1.0);
277 LayoutResponse::shrinkable(Size::new(w, h), Size::new(self.min_w, h), 1.0)
278 }
279 }
280
281 #[test]
282 fn children_get_intrinsic_widths() {
283 let mut tree = WidgetTree::new();
284 let a = tree.add(FixedLeaf(60.0, 30.0));
285 let b = tree.add(FixedLeaf(40.0, 20.0));
286 let _stack = tree.add(HStack::new().child(a).child(b));
287 tree.layout(SizeProposal::exact(300.0, 50.0));
288
289 assert!((tree.bounds(a).width - 60.0).abs() < 0.01);
290 assert!((tree.bounds(b).width - 40.0).abs() < 0.01);
291 assert!((tree.bounds(b).x - 60.0).abs() < 0.01);
292 }
293
294 #[test]
297 fn shrink_distributes_deficit_by_weight() {
298 let mut tree = WidgetTree::new();
300 let a = tree.add(ShrinkLeaf {
301 wanted: 80.0,
302 min: 20.0,
303 shrink: 1.0,
304 height: 20.0,
305 });
306 let b = tree.add(ShrinkLeaf {
307 wanted: 80.0,
308 min: 20.0,
309 shrink: 1.0,
310 height: 20.0,
311 });
312 let _stack = tree.add(HStack::new().child(a).child(b));
313 tree.layout(SizeProposal::exact(100.0, 40.0));
314 assert!(
315 (tree.bounds(a).width - 50.0).abs() < 0.01,
316 "a={}",
317 tree.bounds(a).width
318 );
319 assert!(
320 (tree.bounds(b).width - 50.0).abs() < 0.01,
321 "b={}",
322 tree.bounds(b).width
323 );
324 }
325
326 #[test]
327 fn shrink_priority_leaves_rigid_sibling_untouched() {
328 let mut tree = WidgetTree::new();
330 let label = tree.add(ShrinkLeaf {
331 wanted: 80.0,
332 min: 10.0,
333 shrink: 1.0,
334 height: 20.0,
335 });
336 let icon = tree.add(FixedLeaf(40.0, 20.0)); let _stack = tree.add(HStack::new().child(label).child(icon));
338 tree.layout(SizeProposal::exact(100.0, 40.0)); assert!(
340 (tree.bounds(label).width - 60.0).abs() < 0.01,
341 "label={}",
342 tree.bounds(label).width
343 );
344 assert!(
345 (tree.bounds(icon).width - 40.0).abs() < 0.01,
346 "icon={}",
347 tree.bounds(icon).width
348 );
349 }
350
351 #[test]
352 fn shrink_clamps_at_min_with_residual_overflow() {
353 let mut tree = WidgetTree::new();
355 let a = tree.add(ShrinkLeaf {
356 wanted: 80.0,
357 min: 50.0,
358 shrink: 1.0,
359 height: 20.0,
360 });
361 let _stack = tree.add(HStack::new().child(a));
362 tree.layout(SizeProposal::exact(30.0, 40.0)); assert!(
364 (tree.bounds(a).width - 50.0).abs() < 0.01,
365 "a={}",
366 tree.bounds(a).width
367 );
368 }
369
370 #[test]
371 fn a_much_larger_weight_shrinks_one_child_to_its_floor_first() {
372 let layout = |bounds: f32| {
377 let mut tree = WidgetTree::new();
378 let figures = tree.add(ShrinkLeaf {
379 wanted: 200.0,
380 min: 40.0,
381 shrink: 1000.0,
382 height: 20.0,
383 });
384 let slot = tree.add(ShrinkLeaf {
385 wanted: 100.0,
386 min: 0.0,
387 shrink: 1.0,
388 height: 20.0,
389 });
390 let rigid = tree.add(FixedLeaf(80.0, 20.0));
391 let _stack = tree.add(HStack::new().child(figures).child(slot).child(rigid));
392 tree.layout(SizeProposal::exact(bounds, 40.0));
393 (
394 tree.bounds(figures).width,
395 tree.bounds(slot).width,
396 tree.bounds(rigid).width,
397 )
398 };
399
400 let (figures, slot, rigid) = layout(200.0);
402 assert!((figures - 40.0).abs() < 0.5, "figures={figures}");
403 assert!((slot - 80.0).abs() < 0.5, "slot={slot}");
404 assert!((rigid - 80.0).abs() < 0.01, "rigid={rigid}");
405
406 let (figures, slot, rigid) = layout(300.0);
408 assert!((figures - 120.0).abs() < 0.5, "figures={figures}");
409 assert!((slot - 100.0).abs() < 0.5, "slot={slot}");
410 assert!((rigid - 80.0).abs() < 0.01, "rigid={rigid}");
411 }
412
413 #[test]
414 fn no_shrink_weight_overflows_unchanged() {
415 let mut tree = WidgetTree::new();
417 let a = tree.add(FixedLeaf(80.0, 20.0));
418 let _stack = tree.add(HStack::new().child(a));
419 tree.layout(SizeProposal::exact(50.0, 40.0));
420 assert!(
421 (tree.bounds(a).width - 80.0).abs() < 0.01,
422 "a={}",
423 tree.bounds(a).width
424 );
425 }
426
427 #[test]
430 fn height_for_width_grows_cross_axis_on_shrink() {
431 let mut tree = WidgetTree::new();
435 let leaf = tree.add(AreaLeaf {
436 area: 4000.0,
437 wanted_w: 200.0,
438 min_w: 10.0,
439 });
440 let stack = tree.add(HStack::new().child(leaf));
441 tree.layout(SizeProposal {
442 width: Some(50.0),
443 height: None, });
445 assert!(
446 (tree.bounds(leaf).width - 50.0).abs() < 0.01,
447 "leaf w={}",
448 tree.bounds(leaf).width
449 );
450 assert!(
451 (tree.bounds(leaf).height - 80.0).abs() < 0.5,
452 "leaf h={}",
453 tree.bounds(leaf).height
454 );
455 assert!(
456 (tree.bounds(stack).height - 80.0).abs() < 0.5,
457 "stack height should follow height-for-width, got {}",
458 tree.bounds(stack).height
459 );
460 }
461
462 #[test]
463 fn spacing_between_children() {
464 let mut tree = WidgetTree::new();
465 let a = tree.add(FixedLeaf(50.0, 30.0));
466 let b = tree.add(FixedLeaf(50.0, 30.0));
467 let _stack = tree.add(HStack::new().spacing(10.0).child(a).child(b));
468 tree.layout(SizeProposal::exact(300.0, 50.0));
469
470 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
471 assert!((tree.bounds(b).x - 60.0).abs() < 0.01); }
473
474 #[test]
475 fn cross_axis_center_alignment() {
476 let mut tree = WidgetTree::new();
477 let a = tree.add(FixedLeaf(50.0, 20.0));
478 let _stack = tree.add(HStack::new().child(a)); tree.layout(SizeProposal::exact(200.0, 60.0));
480
481 assert!((tree.bounds(a).y - 20.0).abs() < 0.01);
483 }
484
485 #[test]
486 fn cross_axis_top_alignment() {
487 let mut tree = WidgetTree::new();
488 let a = tree.add(FixedLeaf(50.0, 20.0));
489 let _stack = tree.add(HStack::new().alignment(VAlignment::Top).child(a));
490 tree.layout(SizeProposal::exact(200.0, 60.0));
491
492 assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
493 }
494
495 #[test]
496 fn cross_axis_bottom_alignment() {
497 let mut tree = WidgetTree::new();
498 let a = tree.add(FixedLeaf(50.0, 20.0));
499 let _stack = tree.add(HStack::new().alignment(VAlignment::Bottom).child(a));
500 tree.layout(SizeProposal::exact(200.0, 60.0));
501
502 assert!((tree.bounds(a).y - 40.0).abs() < 0.01); }
504
505 #[test]
506 fn per_child_alignment_override() {
507 let mut tree = WidgetTree::new();
508 let a = tree.add(FixedLeaf(50.0, 20.0));
509 let b = tree.add(FixedLeaf(50.0, 20.0));
510 let _stack = tree.add(HStack::new().alignment(VAlignment::Top).child(a).child(b));
512 tree.set_alignment(
513 b,
514 teksilo_tokens::Alignment {
515 horizontal: teksilo_tokens::HAlignment::Center,
516 vertical: teksilo_tokens::VAlignment::Bottom,
517 },
518 );
519 tree.layout(SizeProposal::exact(200.0, 60.0));
520
521 assert!((tree.bounds(a).y - 0.0).abs() < 0.01); assert!((tree.bounds(b).y - 40.0).abs() < 0.01); }
524
525 #[test]
526 fn intrinsic_size_sums_children() {
527 let stack = HStack::new().spacing(5.0);
528 let theme = teksilo_core::presets::intui::light();
530 let ctx = LayoutContext::for_testing(&theme);
531 let size = stack
532 .layout_response(SizeProposal::exact(100.0, 50.0), &ctx)
533 .size;
534 assert_eq!(size.width, 100.0);
536 assert_eq!(size.height, 50.0);
537 }
538
539 #[test]
540 fn empty_hstack() {
541 let mut tree = WidgetTree::new();
542 let _stack = tree.add(HStack::new());
543 tree.layout(SizeProposal::exact(200.0, 50.0));
544 }
546
547 #[test]
548 fn flex_distributes_proportionally() {
549 use crate::primitives::expand::Expand;
551 let mut tree = WidgetTree::new();
552 let a = tree.add(Expand::new().flex(1.0));
553 let b = tree.add(Expand::new().flex(2.0));
554 let _stack = tree.add(HStack::new().child(a).child(b));
555 tree.layout(SizeProposal::exact(300.0, 50.0));
556
557 assert!(
558 (tree.bounds(a).width - 100.0).abs() < 0.01,
559 "a.width={}",
560 tree.bounds(a).width
561 );
562 assert!(
563 (tree.bounds(b).width - 200.0).abs() < 0.01,
564 "b.width={}",
565 tree.bounds(b).width
566 );
567 }
568
569 #[test]
570 fn flex_with_rigid_floor() {
571 use crate::primitives::expand::Expand;
573 let mut tree = WidgetTree::new();
574 let fixed = tree.add(FixedLeaf(100.0, 30.0));
575 let a = tree.add(Expand::new().flex(1.0));
576 let b = tree.add(Expand::new().flex(2.0));
577 let _stack = tree.add(HStack::new().child(fixed).child(a).child(b));
578 tree.layout(SizeProposal::exact(400.0, 50.0));
579
580 assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
581 assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
582 assert!((tree.bounds(b).width - 200.0).abs() < 0.01);
583 }
584
585 #[test]
586 fn spacer_min_length_is_floor_plus_share() {
587 use crate::primitives::spacer::Spacer;
590 let mut tree = WidgetTree::new();
591 let a = tree.add(Spacer::new().min_length(20.0));
592 let b = tree.add(Spacer::new().min_length(20.0));
593 let _stack = tree.add(HStack::new().child(a).child(b));
594 tree.layout(SizeProposal::exact(100.0, 50.0));
595
596 assert!((tree.bounds(a).width - 50.0).abs() < 0.01);
597 assert!((tree.bounds(b).width - 50.0).abs() < 0.01);
598 }
599
600 #[test]
601 fn expand_default_is_flex_one_in_hstack() {
602 use crate::primitives::expand::Expand;
606 let mut tree = WidgetTree::new();
607 let fixed = tree.add(FixedLeaf(80.0, 30.0));
608 let expand = tree.add(Expand::new());
609 let _stack = tree.add(HStack::new().child(fixed).child(expand));
610 tree.layout(SizeProposal::exact(200.0, 50.0));
611
612 assert!((tree.bounds(fixed).width - 80.0).abs() < 0.01);
613 assert!((tree.bounds(expand).width - 120.0).abs() < 0.01);
614 }
615
616 #[test]
617 fn respect_intrinsic_uses_child_natural_as_floor() {
618 use crate::primitives::expand::Expand;
626 let mut tree = WidgetTree::new();
627 let inner = tree.add(FixedLeaf(60.0, 20.0));
628 let expand = tree.add(Expand::new().respect_intrinsic().child(inner));
629 let fixed = tree.add(FixedLeaf(100.0, 30.0));
630 let _stack = tree.add(HStack::new().child(expand).child(fixed));
631 tree.layout(SizeProposal::exact(300.0, 50.0));
632
633 assert!((tree.bounds(expand).width - 200.0).abs() < 0.01);
634 assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
635 }
636
637 #[test]
638 fn rtl_reverses_child_order() {
639 let mut tree = WidgetTree::new();
640 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
641 let a = tree.add(FixedLeaf(60.0, 30.0));
642 let b = tree.add(FixedLeaf(40.0, 30.0));
643 let _stack = tree.add(HStack::new().child(a).child(b));
644 tree.layout(SizeProposal {
645 width: None,
646 height: Some(50.0),
647 });
648
649 let ab = tree.bounds(a);
652 let bb = tree.bounds(b);
653 assert!(ab.x > bb.x, "a.x={} should be > b.x={} in RTL", ab.x, bb.x);
654 assert!((ab.x - 40.0).abs() < 0.01, "a.x={}", ab.x);
656 assert!((bb.x - 0.0).abs() < 0.01, "b.x={}", bb.x);
658 }
659}