Skip to main content

teksilo_widgets/primitives/
aspect_ratio.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! AspectRatio — a single-child wrapper that constrains layout to a fixed
5//! width-to-height ratio.
6//!
7//! Given a proposal, `AspectRatio` computes the largest rectangle that fits
8//! within both dimensions while satisfying `width / height == ratio`. When
9//! only one axis is constrained by the parent, the other is derived from the
10//! ratio. The child is stretched to the resulting rectangle. The widget is
11//! invisible to assistive technology (`set_hidden`); its child carries all
12//! semantic meaning.
13//!
14//! ## When to use
15//!
16//! - Embedding images, thumbnails, or video placeholders that must stay
17//!   letter-boxed regardless of the available space.
18//! - Ensuring a square avatar or tile layout against an unconstrained parent
19//!   axis.
20//!
21//! ```rust
22//! # use teksilo_widgets::primitives::{AspectRatio, RectWidget};
23//! // 16:9 video placeholder
24//! let _thumbnail = AspectRatio::new(16.0 / 9.0)
25//!     .child(RectWidget::new());
26//! ```
27
28use teksilo_canvas::{Point, Rect, Size, SizeProposal};
29use teksilo_core::accessibility::AccessNodeBuilder;
30use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
31use teksilo_core::widget_id::WidgetId;
32
33/// A single-child wrapper that maintains a fixed width/height ratio.
34#[derive(Debug)]
35pub struct AspectRatio {
36    /// Width divided by height (e.g., 16.0/9.0 for widescreen).
37    ratio: f32,
38    child_id: Option<WidgetId>,
39    pending_child: Option<PendingChild>,
40}
41
42impl AspectRatio {
43    /// Create a new aspect ratio wrapper. Ratio is width / height.
44    pub fn new(ratio: f32) -> Self {
45        Self {
46            ratio: ratio.max(f32::EPSILON),
47            child_id: None,
48            pending_child: None,
49        }
50    }
51
52    /// Convenience for 16:9 aspect ratio.
53    pub fn widescreen() -> Self {
54        Self::new(16.0 / 9.0)
55    }
56
57    /// Convenience for 1:1 aspect ratio.
58    pub fn square() -> Self {
59        Self::new(1.0)
60    }
61
62    /// Set an inline child widget to constrain; the child is stretched to the
63    /// computed aspect-ratio rectangle.
64    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
65        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
66        self
67    }
68
69    /// Set a pre-registered child widget by ID.
70    pub fn child_id(mut self, id: WidgetId) -> Self {
71        self.pending_child = Some(PendingChild::Id(id));
72        self
73    }
74
75    /// Given available space, compute the largest size that fits the ratio.
76    fn constrain(&self, width: Option<f32>, height: Option<f32>) -> Size {
77        match (width, height) {
78            (Some(w), Some(h)) => {
79                // Fit within both constraints
80                let h_from_w = w / self.ratio;
81                let w_from_h = h * self.ratio;
82                if h_from_w <= h {
83                    Size::new(w, h_from_w)
84                } else {
85                    Size::new(w_from_h, h)
86                }
87            }
88            (Some(w), None) => Size::new(w, w / self.ratio),
89            (None, Some(h)) => Size::new(h * self.ratio, h),
90            (None, None) => Size::new(0.0, 0.0),
91        }
92    }
93}
94
95impl Widget for AspectRatio {
96    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
97        if let Some(pending) = self.pending_child.take() {
98            self.child_id = Some(match pending {
99                PendingChild::Id(id) => id,
100                PendingChild::Deferred(w) => ctx.add_boxed(w),
101            });
102        }
103        self.child_id.into_iter().collect()
104    }
105
106    fn layout_response(
107        &self,
108        proposal: SizeProposal,
109        _ctx: &LayoutContext,
110    ) -> teksilo_core::widget::LayoutResponse {
111        self.constrain(proposal.width, proposal.height).into()
112    }
113
114    fn place_children(
115        &self,
116        bounds: Rect,
117        _proposal: SizeProposal,
118        children: &mut [WidgetPlacement],
119        _ctx: &LayoutContext,
120    ) {
121        for child in children.iter_mut() {
122            child.origin = Point::new(bounds.x, bounds.y);
123            child.size = Size::new(bounds.width, bounds.height);
124        }
125    }
126
127    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
128
129    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
130        builder.set_hidden();
131    }
132
133    fn children(&self) -> Vec<WidgetId> {
134        self.child_id.into_iter().collect()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use teksilo_core::widget_tree::WidgetTree;
142
143    #[derive(Debug)]
144    struct FixedLeaf(f32, f32);
145    impl Widget for FixedLeaf {
146        fn layout_response(
147            &self,
148            _proposal: SizeProposal,
149            _ctx: &LayoutContext,
150        ) -> teksilo_core::widget::LayoutResponse {
151            Size::new(self.0, self.1).into()
152        }
153    }
154
155    #[test]
156    fn aspect_ratio_constrains_by_width() {
157        let mut tree = WidgetTree::new();
158        let child = tree.add(FixedLeaf(100.0, 100.0));
159        let ar = tree.add(AspectRatio::new(2.0).child_id(child)); // 2:1
160        tree.layout(SizeProposal {
161            width: Some(200.0),
162            height: None,
163        });
164
165        let b = tree.bounds(ar);
166        assert!((b.width - 200.0).abs() < 0.01);
167        assert!((b.height - 100.0).abs() < 0.01); // 200/2 = 100
168    }
169
170    #[test]
171    fn aspect_ratio_constrains_by_height() {
172        let mut tree = WidgetTree::new();
173        let child = tree.add(FixedLeaf(100.0, 100.0));
174        let ar = tree.add(AspectRatio::new(2.0).child_id(child)); // 2:1
175        tree.layout(SizeProposal {
176            width: None,
177            height: Some(100.0),
178        });
179
180        let b = tree.bounds(ar);
181        // height=100, width_from_height = 100*2 = 200
182        // width_from_width = 400, height = 400/2 = 200 > 100
183        // So constrain by height: 200x100
184        assert!((b.width - 200.0).abs() < 0.01);
185        assert!((b.height - 100.0).abs() < 0.01);
186    }
187
188    #[test]
189    fn square_aspect_ratio() {
190        let mut tree = WidgetTree::new();
191        let child = tree.add(FixedLeaf(50.0, 50.0));
192        let ar = tree.add(AspectRatio::square().child_id(child));
193        tree.layout(SizeProposal {
194            width: None,
195            height: Some(100.0),
196        });
197
198        let b = tree.bounds(ar);
199        assert!((b.width - 100.0).abs() < 0.01);
200        assert!((b.height - 100.0).abs() < 0.01);
201    }
202
203    #[test]
204    fn width_only_proposal() {
205        let ar = AspectRatio::new(4.0 / 3.0);
206        let theme = teksilo_core::presets::intui::light();
207        let ctx = LayoutContext::for_testing(&theme);
208        let size = ar
209            .layout_response(
210                SizeProposal {
211                    width: Some(400.0),
212                    height: None,
213                },
214                &ctx,
215            )
216            .size;
217        assert!((size.width - 400.0).abs() < 0.01);
218        assert!((size.height - 300.0).abs() < 0.01); // 400 / (4/3)
219    }
220}