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}