Skip to main content

teksilo_widgets/primitives/
center.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Center — a single-child wrapper that centers its child within the available
5//! space.
6//!
7//! On a **bounded axis** (the parent proposes an exact size), `Center` fills
8//! that dimension and places the child in the middle. On an **unbounded axis**
9//! (the parent leaves it open, as a stack does on its main axis), `Center`
10//! shrink-wraps to the child's natural size rather than collapsing to zero —
11//! this prevents the child from overflowing a prior sibling. `Center` always
12//! reports `flex = 0`, so it never claims slack from a stack's distribution
13//! pass; to center content *within leftover space*, wrap it in an `Expand`:
14//! `Expand::horizontal().child(Center::new().child(w))`.
15//!
16//! The child is measured **under the constraint `Center` received** (a
17//! loose-but-bounded proposal, like Flutter's `Center`): rigid children keep
18//! their natural size and are centered, while adaptive children respond to
19//! the bound — an ellipsis `TextWidget` truncates at the slot width instead
20//! of overflowing symmetrically, and wrapping text reports its real wrapped
21//! height.
22//!
23//! ## When to use
24//!
25//! - Center a small widget inside a bounded slot (e.g., an icon in a fixed
26//!   square cell).
27//! - Shrink-wrap and center an element inside a layout that provides an exact
28//!   proposal in both axes.
29//!
30//! For claiming *all* remaining stack space and then centering within it, use
31//! [`Expand`](crate::primitives::Expand) wrapping `Center` instead.
32//!
33//! ```rust
34//! # use teksilo_widgets::primitives::{Center, RectWidget};
35//! // Center a rect in the full slot provided by its parent
36//! let _centered = Center::new().child(RectWidget::new());
37//! ```
38
39use teksilo_canvas::{Point, Rect, Size, SizeProposal};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
42use teksilo_core::widget_id::WidgetId;
43
44/// Centers a single child within the space this widget is **given**.
45///
46/// Sizing follows the incoming constraint, per axis: `Center` **fills a
47/// bounded axis** (the tree root, or inside an `Expand` / wrapper that
48/// proposes exact bounds) and **shrink-wraps to the child on an unbounded
49/// axis**. So a bare `Center` does *not* claim slack inside an `HStack` /
50/// `VStack` — those leave their main axis open, and `Center` sizes to its
51/// child there (like Flutter's `Center` / `Align`, or Compose's `Box`),
52/// rather than collapsing to zero and letting the child overflow.
53///
54/// Centering and *expanding* are separate concerns: `Center` reports
55/// `flex = 0` and is a pure alignment wrapper, never a space-claiming one. To
56/// center a child *within the leftover space* of a stack, give it flex with
57/// `Expand` — `Expand::horizontal { Center { child } }` (the analogue of
58/// Flutter's `Expanded(child: Center(...))`).
59#[derive(Debug)]
60pub struct Center {
61    child_id: Option<WidgetId>,
62    pending_child: Option<PendingChild>,
63}
64
65impl Center {
66    /// Create a new `Center` with no child attached.
67    pub fn new() -> Self {
68        Self {
69            child_id: None,
70            pending_child: None,
71        }
72    }
73
74    /// Set child by pre-registered ID.
75    pub fn child_id(mut self, id: WidgetId) -> Self {
76        self.pending_child = Some(PendingChild::Id(id));
77        self
78    }
79
80    /// Set an inline child widget (deferred insertion).
81    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
82        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
83        self
84    }
85}
86
87impl Default for Center {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Widget for Center {
94    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
95        if let Some(pending) = self.pending_child.take() {
96            self.child_id = Some(match pending {
97                PendingChild::Id(id) => id,
98                PendingChild::Deferred(w) => ctx.add_boxed(w),
99            });
100        }
101        self.child_id.into_iter().collect()
102    }
103
104    fn layout_response(
105        &self,
106        proposal: SizeProposal,
107        ctx: &LayoutContext,
108    ) -> teksilo_core::widget::LayoutResponse {
109        // Fill a bounded axis; shrink-wrap to the child on an unbounded one.
110        // A stack leaves its main axis open (`None`) when querying children, so
111        // resolving that to `0` (the old behavior) made `Center` collapse to
112        // zero width/height there and its child overflowed. Sizing to the child
113        // instead keeps `Center` a well-behaved, non-greedy alignment wrapper
114        // (`flex = 0`): it occupies its child on the open axis and fills only
115        // axes the parent actually bounded.
116        //
117        // The child is measured at the *incoming* proposal, not `unspecified()`,
118        // so a bounded axis reaches it (Flutter's loose-but-bounded constraint):
119        // an ellipsis `TextWidget` caps itself at the offered width instead of
120        // reporting its full untruncated line, and a wrapping child measured
121        // under a bounded width reports its real wrapped height on the open
122        // axis (height-for-width) rather than a one-line lie.
123        let child = self
124            .child_id
125            .and_then(|id| ctx.child_size(id, proposal))
126            .unwrap_or(Size::ZERO);
127        Size::new(
128            proposal.width.unwrap_or(child.width),
129            proposal.height.unwrap_or(child.height),
130        )
131        .into()
132    }
133
134    fn place_children(
135        &self,
136        bounds: Rect,
137        _proposal: SizeProposal,
138        children: &mut [WidgetPlacement],
139        ctx: &LayoutContext,
140    ) {
141        // Offer the resolved bounds so adaptive children (ellipsis text,
142        // wrapping paragraphs) cap themselves at the slot instead of taking
143        // their unbounded natural size and overflowing symmetrically around
144        // the center. Rigid children ignore the proposal and are centered at
145        // their natural size, exactly as before.
146        let child_proposal = SizeProposal::exact(bounds.width, bounds.height);
147        for child in children.iter_mut() {
148            let child_size = ctx
149                .child_size(child.id, child_proposal)
150                .unwrap_or(bounds.size());
151            let dx = (bounds.width - child_size.width) / 2.0;
152            let dy = (bounds.height - child_size.height) / 2.0;
153            child.origin = Point::new(bounds.x + dx, bounds.y + dy);
154            child.size = child_size;
155        }
156    }
157
158    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
159
160    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
161        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
162    }
163
164    fn children(&self) -> Vec<WidgetId> {
165        self.child_id.into_iter().collect()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use teksilo_canvas::Size;
173    use teksilo_core::widget_tree::WidgetTree;
174
175    #[derive(Debug)]
176    struct FixedLeaf(f32, f32);
177    impl Widget for FixedLeaf {
178        fn layout_response(
179            &self,
180            _proposal: SizeProposal,
181            _ctx: &LayoutContext,
182        ) -> teksilo_core::widget::LayoutResponse {
183            Size::new(self.0, self.1).into()
184        }
185    }
186
187    #[test]
188    fn centers_child() {
189        let mut tree = WidgetTree::new();
190        let child = tree.add(FixedLeaf(40.0, 20.0));
191        let _center = tree.add(Center::new().child_id(child));
192        tree.layout(SizeProposal::exact(200.0, 100.0));
193
194        let cb = tree.bounds(child);
195        assert!((cb.x - 80.0).abs() < 0.01); // (200-40)/2
196        assert!((cb.y - 40.0).abs() < 0.01); // (100-20)/2
197    }
198
199    #[test]
200    fn claims_full_space() {
201        let mut tree = WidgetTree::new();
202        let child = tree.add(FixedLeaf(40.0, 20.0));
203        let center = tree.add(Center::new().child_id(child));
204        tree.layout(SizeProposal::exact(200.0, 100.0));
205
206        let cb = tree.bounds(center);
207        assert!((cb.width - 200.0).abs() < 0.01);
208        assert!((cb.height - 100.0).abs() < 0.01);
209    }
210
211    /// A leaf that caps itself at the proposed width — the shape of an
212    /// ellipsis `TextWidget` (`min(natural, proposal)`).
213    #[derive(Debug)]
214    struct AdaptiveLeaf {
215        natural_width: f32,
216        height: f32,
217    }
218    impl Widget for AdaptiveLeaf {
219        fn layout_response(
220            &self,
221            proposal: SizeProposal,
222            _ctx: &LayoutContext,
223        ) -> teksilo_core::widget::LayoutResponse {
224            let w = match proposal.width {
225                Some(max) => self.natural_width.min(max),
226                None => self.natural_width,
227            };
228            Size::new(w, self.height).into()
229        }
230    }
231
232    /// Regression: `Center` must offer its bounds to the child so an
233    /// adaptive child (ellipsis text) caps itself at the slot instead of
234    /// being placed at its unbounded natural size, overflowing on both
235    /// sides of the center. This is what lets a `single_line()` placeholder
236    /// truncate with a trailing "…" inside a narrow field.
237    #[test]
238    fn caps_adaptive_child_at_bounds() {
239        let mut tree = WidgetTree::new();
240        let child = tree.add(AdaptiveLeaf {
241            natural_width: 300.0,
242            height: 16.0,
243        });
244        let _center = tree.add(Center::new().child_id(child));
245        tree.layout(SizeProposal::exact(100.0, 40.0));
246
247        let cb = tree.bounds(child);
248        assert!(
249            (cb.width - 100.0).abs() < 0.01,
250            "adaptive child must be capped at Center's width (100), got {}",
251            cb.width
252        );
253        assert!((cb.x - 0.0).abs() < 0.01, "capped child fills, x = 0");
254        assert!((cb.y - 12.0).abs() < 0.01, "still vertically centered");
255
256        // A child narrower than the slot stays centered at its natural size.
257        let mut t2 = WidgetTree::new();
258        let small = t2.add(AdaptiveLeaf {
259            natural_width: 40.0,
260            height: 16.0,
261        });
262        let _c2 = t2.add(Center::new().child_id(small));
263        t2.layout(SizeProposal::exact(100.0, 40.0));
264        let sb = t2.bounds(small);
265        assert!((sb.width - 40.0).abs() < 0.01);
266        assert!((sb.x - 30.0).abs() < 0.01, "(100-40)/2");
267    }
268
269    /// Regression: a bare `Center` inside an `HStack` must **shrink-wrap its
270    /// child**, not collapse to zero width and let the child overflow left over
271    /// a prior sibling. (Old `proposal.resolve(0,0)` yielded width 0, so the
272    /// child was "centered" around x=0 and overlapped the logo.) Also asserts
273    /// the conventional escape — `Expand { Center { .. } }` — claims the slack
274    /// and centers within it.
275    #[test]
276    fn center_in_hstack_shrink_wraps_child_without_overflow() {
277        use crate::primitives::{Expand, HStack};
278
279        // Bare Center: HStack { Fixed(50), Center { Fixed(40) } } in 300px.
280        let mut tree = WidgetTree::new();
281        let logo = tree.add(FixedLeaf(50.0, 20.0));
282        let title = tree.add(FixedLeaf(40.0, 20.0));
283        let center = tree.add(Center::new().child_id(title));
284        let _row = tree.add(HStack::new().add_child(logo).add_child(center));
285        tree.layout(SizeProposal::exact(300.0, 20.0));
286
287        assert!(
288            (tree.bounds(center).width - 40.0).abs() < 0.01,
289            "Center should shrink-wrap its child (40), got {}",
290            tree.bounds(center).width
291        );
292        let logo_right = tree.bounds(logo).x + tree.bounds(logo).width;
293        assert!(
294            tree.bounds(title).x >= logo_right - 0.01,
295            "title must not overflow left over the logo: title.x={}, logo right={}",
296            tree.bounds(title).x,
297            logo_right
298        );
299
300        // Conventional fill+center: Expand claims the 250 slack, Center fills it
301        // and centers the 40px title → title.x = 50 + (250-40)/2 = 155.
302        let mut t2 = WidgetTree::new();
303        let logo2 = t2.add(FixedLeaf(50.0, 20.0));
304        let title2 = t2.add(FixedLeaf(40.0, 20.0));
305        let center2 = t2.add(Center::new().child_id(title2));
306        let exp = t2.add(Expand::horizontal().child_id(center2));
307        let _row2 = t2.add(HStack::new().add_child(logo2).add_child(exp));
308        t2.layout(SizeProposal::exact(300.0, 20.0));
309
310        assert!(
311            (t2.bounds(exp).width - 250.0).abs() < 0.01,
312            "Expand should claim the slack (250), got {}",
313            t2.bounds(exp).width
314        );
315        assert!(
316            (t2.bounds(title2).x - 155.0).abs() < 0.5,
317            "title should be centered in the remaining space (~155), got {}",
318            t2.bounds(title2).x
319        );
320    }
321}