use alloc::{vec, vec::Vec};
use nami::{Computed, Signal, SignalExt};
use waterui_core::{AnyView, IntoSignalF32, View, layout::LayoutInvalidationCallback};
use crate::{
Layout, Point, ProposalSize, Rect, Size, StretchAxis, SubView, SubviewPlacement,
container::FixedContainer,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ContentMode {
#[default]
Fit,
Fill,
}
#[derive(Debug, Clone)]
pub struct AspectRatioLayout {
ratio: Computed<f32>,
mode: ContentMode,
}
impl AspectRatioLayout {
fn resolve(&self, available: ProposalSize, content: Size) -> Size {
let ratio = self.ratio.get();
assert!(
ratio > 0.0 && ratio.is_finite(),
"aspect ratio must be a positive, finite width-to-height ratio, got {ratio}"
);
match (available.width, available.height) {
(Some(width), Some(height)) if width.is_finite() && height.is_finite() => {
let from_width = Size::new(width, width / ratio);
let from_height = Size::new(height * ratio, height);
let width_fits = from_width.height <= height;
let take_width = match self.mode {
ContentMode::Fit => width_fits,
ContentMode::Fill => !width_fits,
};
if take_width { from_width } else { from_height }
}
(Some(width), _) if width.is_finite() => Size::new(width, width / ratio),
(_, Some(height)) if height.is_finite() => Size::new(height * ratio, height),
_ => {
if content.width > 0.0 {
Size::new(content.width, content.width / ratio)
} else {
Size::new(content.height * ratio, content.height)
}
}
}
}
}
impl Layout for AspectRatioLayout {
fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
let content = children
.first()
.map_or(Size::zero(), |child| child.measure(proposal).size);
self.resolve(proposal, content)
}
fn place(
&self,
bounds: Rect,
_proposal: ProposalSize,
children: &[&dyn SubView],
) -> Vec<SubviewPlacement> {
if children.is_empty() {
return vec![];
}
let size = self.resolve(
ProposalSize::new(Some(bounds.width()), Some(bounds.height())),
*bounds.size(),
);
let origin = Point::new(
bounds.x() + (bounds.width() - size.width) * 0.5,
bounds.y() + (bounds.height() - size.height) * 0.5,
);
vec![SubviewPlacement::new(
Rect::new(origin, size),
ProposalSize::new(size.width, size.height),
)]
}
fn stretch_axis(&self, children: &[StretchAxis]) -> StretchAxis {
children.first().copied().unwrap_or_default()
}
fn watch_invalidation(
&self,
invalidate: LayoutInvalidationCallback,
) -> Vec<nami::watcher::BoxWatcherGuard> {
vec![self.ratio.watch(move |_| invalidate())]
}
}
#[derive(Debug)]
pub struct AspectRatio {
layout: AspectRatioLayout,
content: AnyView,
}
impl AspectRatio {
#[must_use]
pub fn new(content: impl View, ratio: impl IntoSignalF32 + 'static, mode: ContentMode) -> Self {
Self {
layout: AspectRatioLayout {
ratio: ratio.into_signal_f32().computed(),
mode,
},
content: AnyView::new(content),
}
}
}
impl View for AspectRatio {
fn body(self, _env: &waterui_core::Environment) -> impl View {
FixedContainer::new(self.layout, (self.content,))
}
fn stretch_axis(&self) -> StretchAxis {
self.layout.stretch_axis(&[self.content.stretch_axis()])
}
}
pub fn aspect_ratio(content: impl View, ratio: impl IntoSignalF32 + 'static) -> AspectRatio {
AspectRatio::new(content, ratio, ContentMode::Fit)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ViewDimensions;
struct Content(Size);
impl SubView for Content {
fn measure(&self, _proposal: ProposalSize) -> ViewDimensions {
ViewDimensions::new(self.0)
}
fn stretch_axis(&self) -> StretchAxis {
StretchAxis::None
}
fn priority(&self) -> i32 {
0
}
}
fn layout(mode: ContentMode) -> AspectRatioLayout {
AspectRatioLayout {
ratio: Computed::constant(2.0),
mode,
}
}
#[test]
fn fit_stays_inside_a_wider_offer() {
let child = Content(Size::new(10.0, 10.0));
let size =
layout(ContentMode::Fit).size_that_fits(ProposalSize::new(300.0, 100.0), &[&child]);
assert!((size.width - 200.0).abs() < 0.01, "got {size:?}");
assert!((size.height - 100.0).abs() < 0.01, "got {size:?}");
}
#[test]
fn fill_covers_a_wider_offer() {
let child = Content(Size::new(10.0, 10.0));
let size =
layout(ContentMode::Fill).size_that_fits(ProposalSize::new(300.0, 100.0), &[&child]);
assert!((size.width - 300.0).abs() < 0.01, "got {size:?}");
assert!((size.height - 150.0).abs() < 0.01, "got {size:?}");
}
#[test]
fn one_offered_axis_decides_the_other() {
let child = Content(Size::new(10.0, 10.0));
let size =
layout(ContentMode::Fit).size_that_fits(ProposalSize::new(Some(80.0), None), &[&child]);
assert!((size.height - 40.0).abs() < 0.01, "got {size:?}");
}
#[test]
fn the_child_sets_the_scale_when_nothing_is_offered() {
let child = Content(Size::new(60.0, 999.0));
let size = layout(ContentMode::Fit).size_that_fits(ProposalSize::UNSPECIFIED, &[&child]);
assert!((size.width - 60.0).abs() < 0.01, "got {size:?}");
assert!((size.height - 30.0).abs() < 0.01, "got {size:?}");
}
#[test]
fn the_child_is_centred_in_the_bounds_it_does_not_fill_bounded_proposal() {
let child = Content(Size::new(10.0, 10.0));
let bounds = Rect::new(Point::zero(), Size::new(300.0, 100.0));
let proposal = ProposalSize::new(Some(bounds.width()), Some(bounds.height()));
let placements = layout(ContentMode::Fit).place(bounds, proposal, &[&child]);
assert!(
(placements[0].frame.width() - 200.0).abs() < 0.01,
"got {:?}",
placements[0].frame
);
assert!(
(placements[0].frame.x() - 50.0).abs() < 0.01,
"got {:?}",
placements[0].frame
);
}
}