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}