1use frus_core::{AlignmentGeometry, Rect, Scene};
4use frus_layout::Style;
5
6use crate::container::Container;
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::{FillAxes, Widget};
10
11pub struct Aligned<Msg = crate::callback::Callback> {
42 inner: Container<Msg>,
43 alignment: AlignmentGeometry,
44 factors: (Option<f32>, Option<f32>),
46 factored: Vec<Box<dyn Widget<Msg>>>,
49 anim: Option<(f32, frus_core::animation::Curve)>,
52}
53
54impl<Msg: Clone + 'static> Aligned<Msg> {
55 pub fn new(alignment: impl Into<AlignmentGeometry>, child: impl Widget<Msg> + 'static) -> Self {
57 let alignment = alignment.into();
58 Self {
59 inner: Container::new().hugging().alignment(alignment).child(child),
60 alignment,
61 factors: (None, None),
62 factored: Vec::new(),
63 anim: None,
64 }
65 }
66
67 pub(crate) fn animated(
73 alignment: impl Into<AlignmentGeometry>,
74 duration: f32,
75 curve: frus_core::animation::Curve,
76 child: impl Widget<Msg> + 'static,
77 ) -> Self {
78 let alignment = alignment.into();
79 Self {
80 inner: Container::new()
81 .hugging()
82 .animated_alignment(alignment, duration, curve.clone())
83 .child(child),
84 alignment,
85 factors: (None, None),
86 factored: Vec::new(),
87 anim: Some((duration, curve)),
88 }
89 }
90
91 pub fn width_factor(mut self, factor: f32) -> Self {
93 self.factors.0 = Some(factor.max(0.0));
94 self.factor()
95 }
96
97 pub fn height_factor(mut self, factor: f32) -> Self {
99 self.factors.1 = Some(factor.max(0.0));
100 self.factor()
101 }
102
103 fn factor(mut self) -> Self {
104 if self.factored.is_empty() {
105 let inner = std::mem::take(&mut self.inner);
106 self.factored.push(Box::new(inner));
107 }
108 self
109 }
110}
111
112pub struct Center<Msg = crate::callback::Callback> {
121 inner: Aligned<Msg>,
122}
123
124impl<Msg: Clone + 'static> Center<Msg> {
125 pub fn new(child: impl Widget<Msg> + 'static) -> Self {
127 Self {
128 inner: Aligned::new(frus_core::Alignment::CENTER, child),
129 }
130 }
131
132 pub fn width_factor(mut self, factor: f32) -> Self {
134 self.inner = self.inner.width_factor(factor);
135 self
136 }
137
138 pub fn height_factor(mut self, factor: f32) -> Self {
140 self.inner = self.inner.height_factor(factor);
141 self
142 }
143}
144
145impl<Msg: Clone> Aligned<Msg> {
146 fn is_factored(&self) -> bool {
147 !self.factored.is_empty()
148 }
149}
150
151impl<Msg: Clone> Widget<Msg> for Aligned<Msg> {
152 fn style(&self) -> Style {
153 match self.is_factored() {
154 true => Style::default(),
155 false => self.inner.style(),
156 }
157 }
158
159 fn style_themed(&self, theme: &Theme) -> Style {
160 match self.is_factored() {
161 true => Style::default(),
162 false => self.inner.style_themed(theme),
163 }
164 }
165
166 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
167 match self.is_factored() {
168 true => &self.factored,
169 false => self.inner.children(),
170 }
171 }
172
173 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
174 }
176
177 fn on_click(&self) -> Option<Msg> {
178 None
179 }
180
181 fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
182 Some(self.alignment)
183 }
184
185 fn anim_offset(&self) -> Option<(f32, f32)> {
186 self.anim.as_ref().map(|_| self.alignment.fractions())
187 }
188
189 fn anim_duration(&self) -> f32 {
190 self.anim.as_ref().map_or(0.0, |(duration, _)| *duration)
191 }
192
193 fn anim_curve(&self) -> frus_core::animation::Curve {
194 self.anim
195 .as_ref()
196 .map_or(frus_core::animation::Curve::Linear, |(_, curve)| {
197 curve.clone()
198 })
199 }
200
201 fn constraints_transform(&self) -> Option<crate::constraints::ConstraintsTransform> {
206 use crate::constraints::AxisConstraint::{AsGiven, Loose};
207 if !self.is_factored() {
208 return None;
209 }
210 let rule = |factor: Option<f32>| match factor {
211 Some(_) => Loose,
212 None => AsGiven,
213 };
214 Some(crate::constraints::ConstraintsTransform {
215 width: rule(self.factors.0),
216 height: rule(self.factors.1),
217 alignment: self.alignment.resolve(frus_core::TextDirection::Ltr),
218 report: false,
219 })
220 }
221
222 fn size_factor(&self) -> Option<(f32, f32)> {
223 self.is_factored()
224 .then(|| (self.factors.0.unwrap_or(1.0), self.factors.1.unwrap_or(1.0)))
225 }
226
227 fn fill_axes(&self, _theme: &Theme) -> FillAxes {
231 FillAxes {
232 horizontal: self.factors.0.is_none(),
233 vertical: self.factors.1.is_none(),
234 }
235 }
236
237 fn debug_name(&self) -> &'static str {
238 "Aligned"
239 }
240}
241
242impl<Msg: Clone> Widget<Msg> for Center<Msg> {
243 fn style(&self) -> Style {
244 self.inner.style()
245 }
246
247 fn style_themed(&self, theme: &Theme) -> Style {
248 self.inner.style_themed(theme)
249 }
250
251 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
252 self.inner.children()
253 }
254
255 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
256
257 fn on_click(&self) -> Option<Msg> {
258 None
259 }
260
261 fn alignment_geometry(&self) -> Option<AlignmentGeometry> {
262 self.inner.alignment_geometry()
263 }
264
265 fn constraints_transform(&self) -> Option<crate::constraints::ConstraintsTransform> {
266 self.inner.constraints_transform()
267 }
268
269 fn size_factor(&self) -> Option<(f32, f32)> {
270 self.inner.size_factor()
271 }
272
273 fn fill_axes(&self, theme: &Theme) -> FillAxes {
274 self.inner.fill_axes(theme)
275 }
276
277 fn debug_name(&self) -> &'static str {
278 "Center"
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::{build_ui, Expanded, Flex, Runtime, Text};
286 use frus_core::{Alignment, Color, Primitive, Size};
287
288 const RED: Color = Color::rgb(1.0, 0.0, 0.0);
289
290 fn square() -> Container<()> {
292 Container::new().width(20.0).height(20.0).color(RED)
293 }
294
295 fn red(root: &dyn Widget<()>, size: Size) -> Rect {
297 let ui = build_ui(root, size, &Runtime::default(), &Theme::dark());
298 ui.scene()
299 .primitives()
300 .iter()
301 .find_map(|p| match p {
302 Primitive::Rect { rect, color, .. } if *color == RED => Some(*rect),
303 _ => None,
304 })
305 .expect("the red square")
306 }
307
308 fn near(rect: Rect, x: f32, y: f32) -> bool {
309 (rect.x - x).abs() < 1.0 && (rect.y - y).abs() < 1.0
310 }
311
312 #[test]
315 fn a_center_alone_centres_on_the_page() {
316 let at = red(&Center::new(square()), Size::new(200.0, 100.0));
317 assert!(near(at, 90.0, 40.0), "{at:?}");
318 }
319
320 #[test]
322 fn a_center_in_a_box_centres_in_the_box() {
323 let root = Container::<()>::new()
324 .width(100.0)
325 .height(60.0)
326 .child(Center::new(square()));
327 let at = red(&root, Size::new(300.0, 300.0));
328 assert!(near(at, 40.0, 20.0), "{at:?}");
329 }
330
331 #[test]
335 fn in_a_column_it_shares_the_line_unless_expanded() {
336 let size = Size::new(200.0, 200.0);
337 let column = Flex::<()>::column()
338 .child(Text::new("title").no_wrap())
339 .child(Center::new(square()));
340 let at = red(&column, size);
341 assert!((at.x - 90.0).abs() < 1.0, "centred across: {at:?}");
342 assert!(
343 at.y < 40.0,
344 "just below the title, not in the middle: {at:?}"
345 );
346
347 let column = Flex::<()>::column()
348 .height(200.0)
349 .child(Text::new("title").no_wrap())
350 .child(Expanded::new(Center::new(square())));
351 let at = red(&column, size);
352 assert!((at.x - 90.0).abs() < 1.0, "{at:?}");
353 assert!(at.y > 90.0, "centred in what the title leaves: {at:?}");
354 }
355
356 #[test]
359 fn in_a_row_it_centres_across_the_row() {
360 let row = Flex::<()>::row()
361 .height(100.0)
362 .child(Text::new("label").no_wrap())
363 .child(Center::new(square()));
364 let at = red(&row, Size::new(300.0, 100.0));
365 assert!((at.y - 40.0).abs() < 1.0, "centred down the row: {at:?}");
366 assert!(at.x < 80.0, "just after the label: {at:?}");
367 }
368
369 #[test]
372 fn a_center_as_a_page_body_centres_under_the_bar() {
373 let size = Size::new(200.0, 400.0);
374 let page = crate::MediaQuery::new(size).scope(|| {
375 crate::Scaffold::<()>::new()
376 .app_bar(
377 crate::AppBar::<()>::new()
378 .title(Text::new("Inbox"))
379 .height(56.0)
380 .build(),
381 )
382 .body(Center::new(square()))
383 .build()
384 });
385 let at = red(page.as_ref(), size);
386 assert!((at.x - 90.0).abs() < 1.0, "{at:?}");
387 assert!(
388 at.y > 190.0 && at.y < 230.0,
389 "centred below the bar: {at:?}"
390 );
391 }
392
393 #[test]
396 fn in_a_scroll_it_is_as_tall_as_its_child() {
397 let scroll = crate::SingleChildScrollView::<()>::new()
398 .width(200.0)
399 .height(300.0)
400 .child(Center::new(square()));
401 let at = red(&scroll, Size::new(200.0, 300.0));
402 assert!((at.x - 90.0).abs() < 1.0, "{at:?}");
403 assert!(at.y < 5.0, "at the top: {at:?}");
404 }
405
406 #[test]
408 fn aligned_places_at_any_anchor() {
409 let size = Size::new(100.0, 100.0);
410 let at = red(&Aligned::new(Alignment::BOTTOM_RIGHT, square()), size);
411 assert!(near(at, 80.0, 80.0), "{at:?}");
412 let at = red(&Aligned::new(Alignment::new(0.5, -0.5), square()), size);
413 assert!(near(at, 60.0, 20.0), "{at:?}");
414 let at = red(
415 &Aligned::new(frus_core::AlignmentDirectional::CENTER_START, square()),
416 size,
417 );
418 assert!(near(at, 0.0, 40.0), "{at:?}");
419 }
420
421 const BLUE: Color = Color::rgb(0.0, 0.0, 1.0);
422
423 fn painted(
425 root: &dyn Widget<()>,
426 size: Size,
427 theme: &Theme,
428 color: Color,
429 ) -> Vec<(Rect, Rect)> {
430 let ui = build_ui(root, size, &Runtime::default(), theme);
431 ui.scene()
432 .primitives()
433 .iter()
434 .filter_map(|p| match p {
435 Primitive::Rect {
436 rect,
437 clip,
438 color: c,
439 ..
440 } if *c == color => Some((*rect, *clip)),
441 _ => None,
442 })
443 .collect()
444 }
445
446 fn over_a_bar(anchor: impl Widget<()> + 'static) -> Flex<()> {
448 Flex::<()>::column()
449 .child(anchor)
450 .child(Container::new().width(10.0).height(10.0).color(BLUE))
451 }
452
453 fn bar_top(root: &dyn Widget<()>, size: Size) -> f32 {
454 painted(root, size, &Theme::dark(), BLUE)[0].0.y
455 }
456
457 #[test]
461 fn a_height_factor_makes_the_box_a_multiple_of_the_child() {
462 let size = Size::new(200.0, 200.0);
463 let root = over_a_bar(Center::new(Container::new().child(square())).height_factor(3.0));
464 let at = red(&root, size);
465 assert!(near(at, 90.0, 20.0), "{at:?}");
466 assert!((bar_top(&root, size) - 60.0).abs() < 0.5);
467
468 let root = over_a_bar(Aligned::new(Alignment::BOTTOM_RIGHT, square()).height_factor(2.0));
469 let at = red(&root, size);
470 assert!(near(at, 180.0, 20.0), "{at:?}");
471 assert!((bar_top(&root, size) - 40.0).abs() < 0.5);
472
473 let at = red(&Center::new(square()).height_factor(2.0), size);
476 assert!(near(at, 90.0, 10.0), "{at:?}");
477 }
478
479 #[test]
481 fn a_width_factor_makes_the_box_a_multiple_across() {
482 let root = Flex::<()>::row()
483 .height(100.0)
484 .child(Center::new(square()).width_factor(3.0))
485 .child(Container::new().width(10.0).height(10.0).color(BLUE));
486 let size = Size::new(300.0, 100.0);
487 let at = red(&root, size);
488 assert!(near(at, 20.0, 40.0), "{at:?}");
489 let bar = painted(&root, size, &Theme::dark(), BLUE)[0].0;
490 assert!((bar.x - 60.0).abs() < 0.5, "{bar:?}");
491 }
492
493 #[test]
496 fn factors_of_one_hug_the_child() {
497 let root = Container::<()>::new().child(over_a_bar(
498 Center::new(square()).width_factor(1.0).height_factor(1.0),
499 ));
500 let size = Size::new(200.0, 200.0);
501 let at = red(&root, size);
502 assert!(near(at, 0.0, 0.0), "{at:?}");
503 assert!((bar_top(&root, size) - 20.0).abs() < 0.5);
504 }
505
506 #[test]
509 fn a_factor_below_one_lets_the_child_spill() {
510 let size = Size::new(200.0, 200.0);
511 let root = over_a_bar(Aligned::new(Alignment::TOP_CENTER, square()).height_factor(0.5));
512 let (at, clip) = painted(&root, size, &Theme::dark(), RED)[0];
513 assert!(near(at, 90.0, 0.0), "{at:?}");
514 assert!(clip.height >= 20.0, "not cut to the box: {clip:?}");
515 assert!((bar_top(&root, size) - 10.0).abs() < 0.5);
516 }
517
518 #[test]
521 fn a_start_anchor_with_a_factor_follows_the_script() {
522 let size = Size::new(200.0, 100.0);
523 let root = Flex::<()>::row().width(200.0).height(100.0).child(
524 Aligned::new(frus_core::AlignmentDirectional::CENTER_START, square()).width_factor(4.0),
525 );
526 let ltr = painted(&root, size, &Theme::dark(), RED)[0].0;
527 assert!(near(ltr, 0.0, 40.0), "{ltr:?}");
528 let rtl = painted(&root, size, &Theme::dark().rtl(), RED)[0].0;
529 assert!(near(rtl, 180.0, 40.0), "{rtl:?}");
530 }
531
532 #[test]
534 fn a_new_factor_is_a_new_box() {
535 let runtime = Runtime::default();
536 let size = Size::new(200.0, 200.0);
537 let tops: Vec<f32> = [2.0, 3.0]
538 .into_iter()
539 .map(|factor| {
540 let root = over_a_bar(Center::new(square()).height_factor(factor));
541 let ui = build_ui(&root, size, &runtime, &Theme::dark());
542 ui.scene()
543 .primitives()
544 .iter()
545 .find_map(|p| match p {
546 Primitive::Rect { rect, color, .. } if *color == BLUE => Some(rect.y),
547 _ => None,
548 })
549 .expect("the bar")
550 })
551 .collect();
552 assert_eq!(tops, vec![40.0, 60.0]);
553 }
554
555 #[test]
558 fn a_paragraph_under_a_width_factor_wraps_at_the_room() {
559 let size = Size::new(120.0, 400.0);
560 let words = "one two three four five six seven eight nine ten eleven twelve";
561 let height = |text: &str| {
562 bar_top(
563 &over_a_bar(Aligned::new(Alignment::TOP_LEFT, Text::new(text)).width_factor(1.0)),
564 size,
565 )
566 };
567 let line = height("one");
568 let paragraph = height(words);
569 assert!(
570 paragraph > 2.5 * line,
571 "wrapped: {paragraph} against {line}"
572 );
573 }
574
575 #[test]
578 fn a_paragraph_under_a_small_factor_keeps_its_lines() {
579 let words = "one two three four five six seven eight nine ten eleven twelve";
580 let root =
581 over_a_bar(Aligned::new(Alignment::TOP_LEFT, Text::new(words)).width_factor(0.5));
582 let ui = build_ui(
583 &root,
584 Size::new(120.0, 400.0),
585 &Runtime::default(),
586 &Theme::dark(),
587 );
588 let wraps_at = ui
589 .scene()
590 .primitives()
591 .iter()
592 .find_map(|p| match p {
593 Primitive::Text { max_width, .. } => Some(*max_width),
594 _ => None,
595 })
596 .expect("the paragraph");
597 assert_eq!(wraps_at, Some(120.0));
598 }
599}