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 add_child(mut self, id: WidgetId) -> Self {
74 self.pending.push(PendingChild::Id(id));
75 self
76 }
77
78 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
80 self.pending.push(PendingChild::Deferred(Box::new(widget)));
81 self
82 }
83
84 pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
86 for widget in iter {
87 self.pending.push(PendingChild::Deferred(Box::new(widget)));
88 }
89 self
90 }
91
92 pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
94 if let Some(w) = widget {
95 self.pending.push(PendingChild::Deferred(Box::new(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().add_child(a).add_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().add_child(a).add_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().add_child(label).add_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().add_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 no_shrink_weight_overflows_unchanged() {
372 let mut tree = WidgetTree::new();
374 let a = tree.add(FixedLeaf(80.0, 20.0));
375 let _stack = tree.add(HStack::new().add_child(a));
376 tree.layout(SizeProposal::exact(50.0, 40.0));
377 assert!(
378 (tree.bounds(a).width - 80.0).abs() < 0.01,
379 "a={}",
380 tree.bounds(a).width
381 );
382 }
383
384 #[test]
387 fn height_for_width_grows_cross_axis_on_shrink() {
388 let mut tree = WidgetTree::new();
392 let leaf = tree.add(AreaLeaf {
393 area: 4000.0,
394 wanted_w: 200.0,
395 min_w: 10.0,
396 });
397 let stack = tree.add(HStack::new().add_child(leaf));
398 tree.layout(SizeProposal {
399 width: Some(50.0),
400 height: None, });
402 assert!(
403 (tree.bounds(leaf).width - 50.0).abs() < 0.01,
404 "leaf w={}",
405 tree.bounds(leaf).width
406 );
407 assert!(
408 (tree.bounds(leaf).height - 80.0).abs() < 0.5,
409 "leaf h={}",
410 tree.bounds(leaf).height
411 );
412 assert!(
413 (tree.bounds(stack).height - 80.0).abs() < 0.5,
414 "stack height should follow height-for-width, got {}",
415 tree.bounds(stack).height
416 );
417 }
418
419 #[test]
420 fn spacing_between_children() {
421 let mut tree = WidgetTree::new();
422 let a = tree.add(FixedLeaf(50.0, 30.0));
423 let b = tree.add(FixedLeaf(50.0, 30.0));
424 let _stack = tree.add(HStack::new().spacing(10.0).add_child(a).add_child(b));
425 tree.layout(SizeProposal::exact(300.0, 50.0));
426
427 assert!((tree.bounds(a).x - 0.0).abs() < 0.01);
428 assert!((tree.bounds(b).x - 60.0).abs() < 0.01); }
430
431 #[test]
432 fn cross_axis_center_alignment() {
433 let mut tree = WidgetTree::new();
434 let a = tree.add(FixedLeaf(50.0, 20.0));
435 let _stack = tree.add(HStack::new().add_child(a)); tree.layout(SizeProposal::exact(200.0, 60.0));
437
438 assert!((tree.bounds(a).y - 20.0).abs() < 0.01);
440 }
441
442 #[test]
443 fn cross_axis_top_alignment() {
444 let mut tree = WidgetTree::new();
445 let a = tree.add(FixedLeaf(50.0, 20.0));
446 let _stack = tree.add(HStack::new().alignment(VAlignment::Top).add_child(a));
447 tree.layout(SizeProposal::exact(200.0, 60.0));
448
449 assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
450 }
451
452 #[test]
453 fn cross_axis_bottom_alignment() {
454 let mut tree = WidgetTree::new();
455 let a = tree.add(FixedLeaf(50.0, 20.0));
456 let _stack = tree.add(HStack::new().alignment(VAlignment::Bottom).add_child(a));
457 tree.layout(SizeProposal::exact(200.0, 60.0));
458
459 assert!((tree.bounds(a).y - 40.0).abs() < 0.01); }
461
462 #[test]
463 fn per_child_alignment_override() {
464 let mut tree = WidgetTree::new();
465 let a = tree.add(FixedLeaf(50.0, 20.0));
466 let b = tree.add(FixedLeaf(50.0, 20.0));
467 let _stack = tree.add(
469 HStack::new()
470 .alignment(VAlignment::Top)
471 .add_child(a)
472 .add_child(b),
473 );
474 tree.set_alignment(
475 b,
476 teksilo_tokens::Alignment {
477 horizontal: teksilo_tokens::HAlignment::Center,
478 vertical: teksilo_tokens::VAlignment::Bottom,
479 },
480 );
481 tree.layout(SizeProposal::exact(200.0, 60.0));
482
483 assert!((tree.bounds(a).y - 0.0).abs() < 0.01); assert!((tree.bounds(b).y - 40.0).abs() < 0.01); }
486
487 #[test]
488 fn intrinsic_size_sums_children() {
489 let stack = HStack::new().spacing(5.0);
490 let theme = teksilo_core::presets::intui::light();
492 let ctx = LayoutContext::for_testing(&theme);
493 let size = stack
494 .layout_response(SizeProposal::exact(100.0, 50.0), &ctx)
495 .size;
496 assert_eq!(size.width, 100.0);
498 assert_eq!(size.height, 50.0);
499 }
500
501 #[test]
502 fn empty_hstack() {
503 let mut tree = WidgetTree::new();
504 let _stack = tree.add(HStack::new());
505 tree.layout(SizeProposal::exact(200.0, 50.0));
506 }
508
509 #[test]
510 fn flex_distributes_proportionally() {
511 use crate::primitives::expand::Expand;
513 let mut tree = WidgetTree::new();
514 let a = tree.add(Expand::new().flex(1.0));
515 let b = tree.add(Expand::new().flex(2.0));
516 let _stack = tree.add(HStack::new().add_child(a).add_child(b));
517 tree.layout(SizeProposal::exact(300.0, 50.0));
518
519 assert!(
520 (tree.bounds(a).width - 100.0).abs() < 0.01,
521 "a.width={}",
522 tree.bounds(a).width
523 );
524 assert!(
525 (tree.bounds(b).width - 200.0).abs() < 0.01,
526 "b.width={}",
527 tree.bounds(b).width
528 );
529 }
530
531 #[test]
532 fn flex_with_rigid_floor() {
533 use crate::primitives::expand::Expand;
535 let mut tree = WidgetTree::new();
536 let fixed = tree.add(FixedLeaf(100.0, 30.0));
537 let a = tree.add(Expand::new().flex(1.0));
538 let b = tree.add(Expand::new().flex(2.0));
539 let _stack = tree.add(HStack::new().add_child(fixed).add_child(a).add_child(b));
540 tree.layout(SizeProposal::exact(400.0, 50.0));
541
542 assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
543 assert!((tree.bounds(a).width - 100.0).abs() < 0.01);
544 assert!((tree.bounds(b).width - 200.0).abs() < 0.01);
545 }
546
547 #[test]
548 fn spacer_min_length_is_floor_plus_share() {
549 use crate::primitives::spacer::Spacer;
552 let mut tree = WidgetTree::new();
553 let a = tree.add(Spacer::new().min_length(20.0));
554 let b = tree.add(Spacer::new().min_length(20.0));
555 let _stack = tree.add(HStack::new().add_child(a).add_child(b));
556 tree.layout(SizeProposal::exact(100.0, 50.0));
557
558 assert!((tree.bounds(a).width - 50.0).abs() < 0.01);
559 assert!((tree.bounds(b).width - 50.0).abs() < 0.01);
560 }
561
562 #[test]
563 fn expand_default_is_flex_one_in_hstack() {
564 use crate::primitives::expand::Expand;
568 let mut tree = WidgetTree::new();
569 let fixed = tree.add(FixedLeaf(80.0, 30.0));
570 let expand = tree.add(Expand::new());
571 let _stack = tree.add(HStack::new().add_child(fixed).add_child(expand));
572 tree.layout(SizeProposal::exact(200.0, 50.0));
573
574 assert!((tree.bounds(fixed).width - 80.0).abs() < 0.01);
575 assert!((tree.bounds(expand).width - 120.0).abs() < 0.01);
576 }
577
578 #[test]
579 fn respect_intrinsic_uses_child_natural_as_floor() {
580 use crate::primitives::expand::Expand;
588 let mut tree = WidgetTree::new();
589 let inner = tree.add(FixedLeaf(60.0, 20.0));
590 let expand = tree.add(Expand::new().respect_intrinsic().child_id(inner));
591 let fixed = tree.add(FixedLeaf(100.0, 30.0));
592 let _stack = tree.add(HStack::new().add_child(expand).add_child(fixed));
593 tree.layout(SizeProposal::exact(300.0, 50.0));
594
595 assert!((tree.bounds(expand).width - 200.0).abs() < 0.01);
596 assert!((tree.bounds(fixed).width - 100.0).abs() < 0.01);
597 }
598
599 #[test]
600 fn rtl_reverses_child_order() {
601 let mut tree = WidgetTree::new();
602 tree.set_layout_direction(teksilo_core::environment::LayoutDirection::RightToLeft);
603 let a = tree.add(FixedLeaf(60.0, 30.0));
604 let b = tree.add(FixedLeaf(40.0, 30.0));
605 let _stack = tree.add(HStack::new().add_child(a).add_child(b));
606 tree.layout(SizeProposal {
607 width: None,
608 height: Some(50.0),
609 });
610
611 let ab = tree.bounds(a);
614 let bb = tree.bounds(b);
615 assert!(ab.x > bb.x, "a.x={} should be > b.x={} in RTL", ab.x, bb.x);
616 assert!((ab.x - 40.0).abs() < 0.01, "a.x={}", ab.x);
618 assert!((bb.x - 0.0).abs() < 0.01, "b.x={}", bb.x);
620 }
621}