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 an inline child widget (deferred insertion).
75    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
76        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
77        self
78    }
79    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
80    ///
81    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
82    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
83    /// adds no arena node, so nothing is laid out, painted, or published to the
84    /// accessibility tree, and a stack applies no spacing around it.
85    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
86        match widget {
87            Some(w) => self.child(w),
88            None => self,
89        }
90    }
91}
92
93impl Default for Center {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl Widget for Center {
100    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
101        if let Some(pending) = self.pending_child.take() {
102            self.child_id = Some(match pending {
103                PendingChild::Id(id) => id,
104                PendingChild::Deferred(w) => ctx.add_boxed(w),
105            });
106        }
107        self.child_id.into_iter().collect()
108    }
109
110    fn layout_response(
111        &self,
112        proposal: SizeProposal,
113        ctx: &LayoutContext,
114    ) -> teksilo_core::widget::LayoutResponse {
115        // Fill a bounded axis; shrink-wrap to the child on an unbounded one.
116        // A stack leaves its main axis open (`None`) when querying children, so
117        // resolving that to `0` (the old behavior) made `Center` collapse to
118        // zero width/height there and its child overflowed. Sizing to the child
119        // instead keeps `Center` a well-behaved, non-greedy alignment wrapper
120        // (`flex = 0`): it occupies its child on the open axis and fills only
121        // axes the parent actually bounded.
122        //
123        // The child is measured at the *incoming* proposal, not `unspecified()`,
124        // so a bounded axis reaches it (Flutter's loose-but-bounded constraint):
125        // an ellipsis `TextWidget` caps itself at the offered width instead of
126        // reporting its full untruncated line, and a wrapping child measured
127        // under a bounded width reports its real wrapped height on the open
128        // axis (height-for-width) rather than a one-line lie.
129        let child = self
130            .child_id
131            .and_then(|id| ctx.child_size(id, proposal))
132            .unwrap_or(Size::ZERO);
133        Size::new(
134            proposal.width.unwrap_or(child.width),
135            proposal.height.unwrap_or(child.height),
136        )
137        .into()
138    }
139
140    fn place_children(
141        &self,
142        bounds: Rect,
143        _proposal: SizeProposal,
144        children: &mut [WidgetPlacement],
145        ctx: &LayoutContext,
146    ) {
147        // Offer the resolved bounds so adaptive children (ellipsis text,
148        // wrapping paragraphs) cap themselves at the slot instead of taking
149        // their unbounded natural size and overflowing symmetrically around
150        // the center. Rigid children ignore the proposal and are centered at
151        // their natural size, exactly as before.
152        let child_proposal = SizeProposal::exact(bounds.width, bounds.height);
153        for child in children.iter_mut() {
154            let child_size = ctx
155                .child_size(child.id, child_proposal)
156                .unwrap_or(bounds.size());
157            let dx = (bounds.width - child_size.width) / 2.0;
158            let dy = (bounds.height - child_size.height) / 2.0;
159            child.origin = Point::new(bounds.x + dx, bounds.y + dy);
160            child.size = child_size;
161        }
162    }
163
164    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
165
166    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
167        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
168    }
169
170    fn children(&self) -> Vec<WidgetId> {
171        self.child_id.into_iter().collect()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use teksilo_canvas::Size;
179    use teksilo_core::widget_tree::WidgetTree;
180
181    #[derive(Debug)]
182    struct FixedLeaf(f32, f32);
183    impl Widget for FixedLeaf {
184        fn layout_response(
185            &self,
186            _proposal: SizeProposal,
187            _ctx: &LayoutContext,
188        ) -> teksilo_core::widget::LayoutResponse {
189            Size::new(self.0, self.1).into()
190        }
191    }
192
193    #[test]
194    fn centers_child() {
195        let mut tree = WidgetTree::new();
196        let child = tree.add(FixedLeaf(40.0, 20.0));
197        let _center = tree.add(Center::new().child(child));
198        tree.layout(SizeProposal::exact(200.0, 100.0));
199
200        let cb = tree.bounds(child);
201        assert!((cb.x - 80.0).abs() < 0.01); // (200-40)/2
202        assert!((cb.y - 40.0).abs() < 0.01); // (100-20)/2
203    }
204
205    #[test]
206    fn claims_full_space() {
207        let mut tree = WidgetTree::new();
208        let child = tree.add(FixedLeaf(40.0, 20.0));
209        let center = tree.add(Center::new().child(child));
210        tree.layout(SizeProposal::exact(200.0, 100.0));
211
212        let cb = tree.bounds(center);
213        assert!((cb.width - 200.0).abs() < 0.01);
214        assert!((cb.height - 100.0).abs() < 0.01);
215    }
216
217    /// A leaf that caps itself at the proposed width — the shape of an
218    /// ellipsis `TextWidget` (`min(natural, proposal)`).
219    #[derive(Debug)]
220    struct AdaptiveLeaf {
221        natural_width: f32,
222        height: f32,
223    }
224    impl Widget for AdaptiveLeaf {
225        fn layout_response(
226            &self,
227            proposal: SizeProposal,
228            _ctx: &LayoutContext,
229        ) -> teksilo_core::widget::LayoutResponse {
230            let w = match proposal.width {
231                Some(max) => self.natural_width.min(max),
232                None => self.natural_width,
233            };
234            Size::new(w, self.height).into()
235        }
236    }
237
238    /// Regression: `Center` must offer its bounds to the child so an
239    /// adaptive child (ellipsis text) caps itself at the slot instead of
240    /// being placed at its unbounded natural size, overflowing on both
241    /// sides of the center. This is what lets a `single_line()` placeholder
242    /// truncate with a trailing "…" inside a narrow field.
243    #[test]
244    fn caps_adaptive_child_at_bounds() {
245        let mut tree = WidgetTree::new();
246        let child = tree.add(AdaptiveLeaf {
247            natural_width: 300.0,
248            height: 16.0,
249        });
250        let _center = tree.add(Center::new().child(child));
251        tree.layout(SizeProposal::exact(100.0, 40.0));
252
253        let cb = tree.bounds(child);
254        assert!(
255            (cb.width - 100.0).abs() < 0.01,
256            "adaptive child must be capped at Center's width (100), got {}",
257            cb.width
258        );
259        assert!((cb.x - 0.0).abs() < 0.01, "capped child fills, x = 0");
260        assert!((cb.y - 12.0).abs() < 0.01, "still vertically centered");
261
262        // A child narrower than the slot stays centered at its natural size.
263        let mut t2 = WidgetTree::new();
264        let small = t2.add(AdaptiveLeaf {
265            natural_width: 40.0,
266            height: 16.0,
267        });
268        let _c2 = t2.add(Center::new().child(small));
269        t2.layout(SizeProposal::exact(100.0, 40.0));
270        let sb = t2.bounds(small);
271        assert!((sb.width - 40.0).abs() < 0.01);
272        assert!((sb.x - 30.0).abs() < 0.01, "(100-40)/2");
273    }
274
275    /// Regression: a bare `Center` inside an `HStack` must **shrink-wrap its
276    /// child**, not collapse to zero width and let the child overflow left over
277    /// a prior sibling. (Old `proposal.resolve(0,0)` yielded width 0, so the
278    /// child was "centered" around x=0 and overlapped the logo.) Also asserts
279    /// the conventional escape — `Expand { Center { .. } }` — claims the slack
280    /// and centers within it.
281    #[test]
282    fn center_in_hstack_shrink_wraps_child_without_overflow() {
283        use crate::primitives::{Expand, HStack};
284
285        // Bare Center: HStack { Fixed(50), Center { Fixed(40) } } in 300px.
286        let mut tree = WidgetTree::new();
287        let logo = tree.add(FixedLeaf(50.0, 20.0));
288        let title = tree.add(FixedLeaf(40.0, 20.0));
289        let center = tree.add(Center::new().child(title));
290        let _row = tree.add(HStack::new().child(logo).child(center));
291        tree.layout(SizeProposal::exact(300.0, 20.0));
292
293        assert!(
294            (tree.bounds(center).width - 40.0).abs() < 0.01,
295            "Center should shrink-wrap its child (40), got {}",
296            tree.bounds(center).width
297        );
298        let logo_right = tree.bounds(logo).x + tree.bounds(logo).width;
299        assert!(
300            tree.bounds(title).x >= logo_right - 0.01,
301            "title must not overflow left over the logo: title.x={}, logo right={}",
302            tree.bounds(title).x,
303            logo_right
304        );
305
306        // Conventional fill+center: Expand claims the 250 slack, Center fills it
307        // and centers the 40px title → title.x = 50 + (250-40)/2 = 155.
308        let mut t2 = WidgetTree::new();
309        let logo2 = t2.add(FixedLeaf(50.0, 20.0));
310        let title2 = t2.add(FixedLeaf(40.0, 20.0));
311        let center2 = t2.add(Center::new().child(title2));
312        let exp = t2.add(Expand::horizontal().child(center2));
313        let _row2 = t2.add(HStack::new().child(logo2).child(exp));
314        t2.layout(SizeProposal::exact(300.0, 20.0));
315
316        assert!(
317            (t2.bounds(exp).width - 250.0).abs() < 0.01,
318            "Expand should claim the slack (250), got {}",
319            t2.bounds(exp).width
320        );
321        assert!(
322            (t2.bounds(title2).x - 155.0).abs() < 0.5,
323            "title should be centered in the remaining space (~155), got {}",
324            t2.bounds(title2).x
325        );
326    }
327}