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
11//! publishes no node of its own to assistive technology: it is a bare
12//! `Role::GenericContainer`, which the walker prunes, and its child carries
13//! all semantic meaning. It is never hidden, which would take the child out of
14//! every platform's tree with it.
15//!
16//! ## When to use
17//!
18//! - Embedding images, thumbnails, or video placeholders that must stay
19//!   letter-boxed regardless of the available space.
20//! - Ensuring a square avatar or tile layout against an unconstrained parent
21//!   axis.
22//!
23//! ```rust
24//! # use teksilo_widgets::primitives::{AspectRatio, RectWidget};
25//! // 16:9 video placeholder
26//! let _thumbnail = AspectRatio::new(16.0 / 9.0)
27//!     .child(RectWidget::new());
28//! ```
29
30use teksilo_canvas::{Point, Rect, Size, SizeProposal};
31use teksilo_core::accessibility::AccessNodeBuilder;
32use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
33use teksilo_core::widget_id::WidgetId;
34
35/// A single-child wrapper that maintains a fixed width/height ratio.
36#[derive(Debug)]
37pub struct AspectRatio {
38    /// Width divided by height (e.g., 16.0/9.0 for widescreen).
39    ratio: f32,
40    child_id: Option<WidgetId>,
41    pending_child: Option<PendingChild>,
42}
43
44impl AspectRatio {
45    /// Create a new aspect ratio wrapper. Ratio is width / height.
46    pub fn new(ratio: f32) -> Self {
47        Self {
48            ratio: ratio.max(f32::EPSILON),
49            child_id: None,
50            pending_child: None,
51        }
52    }
53
54    /// Convenience for 16:9 aspect ratio.
55    pub fn widescreen() -> Self {
56        Self::new(16.0 / 9.0)
57    }
58
59    /// Convenience for 1:1 aspect ratio.
60    pub fn square() -> Self {
61        Self::new(1.0)
62    }
63
64    /// Set an inline child widget to constrain; the child is stretched to the
65    /// computed aspect-ratio rectangle.
66    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
67        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
68        self
69    }
70    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
71    ///
72    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
73    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
74    /// adds no arena node, so nothing is laid out, painted, or published to the
75    /// accessibility tree, and a stack applies no spacing around it.
76    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
77        match widget {
78            Some(w) => self.child(w),
79            None => self,
80        }
81    }
82
83    /// Given available space, compute the largest size that fits the ratio.
84    fn constrain(&self, width: Option<f32>, height: Option<f32>) -> Size {
85        match (width, height) {
86            (Some(w), Some(h)) => {
87                // Fit within both constraints
88                let h_from_w = w / self.ratio;
89                let w_from_h = h * self.ratio;
90                if h_from_w <= h {
91                    Size::new(w, h_from_w)
92                } else {
93                    Size::new(w_from_h, h)
94                }
95            }
96            (Some(w), None) => Size::new(w, w / self.ratio),
97            (None, Some(h)) => Size::new(h * self.ratio, h),
98            (None, None) => Size::new(0.0, 0.0),
99        }
100    }
101}
102
103impl Widget for AspectRatio {
104    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
105        if let Some(pending) = self.pending_child.take() {
106            self.child_id = Some(match pending {
107                PendingChild::Id(id) => id,
108                PendingChild::Deferred(w) => ctx.add_boxed(w),
109            });
110        }
111        self.child_id.into_iter().collect()
112    }
113
114    fn layout_response(
115        &self,
116        proposal: SizeProposal,
117        _ctx: &LayoutContext,
118    ) -> teksilo_core::widget::LayoutResponse {
119        self.constrain(proposal.width, proposal.height).into()
120    }
121
122    fn place_children(
123        &self,
124        bounds: Rect,
125        _proposal: SizeProposal,
126        children: &mut [WidgetPlacement],
127        _ctx: &LayoutContext,
128    ) {
129        for child in children.iter_mut() {
130            child.origin = Point::new(bounds.x, bounds.y);
131            child.size = Size::new(bounds.width, bounds.height);
132        }
133    }
134
135    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
136
137    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
138        // A layout wrapper says nothing of its own: a bare `GenericContainer`,
139        // which the walker prunes and every adapter drops with its child
140        // kept. Never `set_hidden()`, which an adapter reads as hiding the
141        // whole subtree: whatever this wraps went with it.
142        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
143    }
144
145    fn children(&self) -> Vec<WidgetId> {
146        self.child_id.into_iter().collect()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use teksilo_core::widget_tree::WidgetTree;
154
155    #[derive(Debug)]
156    struct FixedLeaf(f32, f32);
157    impl Widget for FixedLeaf {
158        fn layout_response(
159            &self,
160            _proposal: SizeProposal,
161            _ctx: &LayoutContext,
162        ) -> teksilo_core::widget::LayoutResponse {
163            Size::new(self.0, self.1).into()
164        }
165    }
166
167    #[test]
168    fn aspect_ratio_constrains_by_width() {
169        let mut tree = WidgetTree::new();
170        let child = tree.add(FixedLeaf(100.0, 100.0));
171        let ar = tree.add(AspectRatio::new(2.0).child(child)); // 2:1
172        tree.layout(SizeProposal {
173            width: Some(200.0),
174            height: None,
175        });
176
177        let b = tree.bounds(ar);
178        assert!((b.width - 200.0).abs() < 0.01);
179        assert!((b.height - 100.0).abs() < 0.01); // 200/2 = 100
180    }
181
182    #[test]
183    fn aspect_ratio_constrains_by_height() {
184        let mut tree = WidgetTree::new();
185        let child = tree.add(FixedLeaf(100.0, 100.0));
186        let ar = tree.add(AspectRatio::new(2.0).child(child)); // 2:1
187        tree.layout(SizeProposal {
188            width: None,
189            height: Some(100.0),
190        });
191
192        let b = tree.bounds(ar);
193        // height=100, width_from_height = 100*2 = 200
194        // width_from_width = 400, height = 400/2 = 200 > 100
195        // So constrain by height: 200x100
196        assert!((b.width - 200.0).abs() < 0.01);
197        assert!((b.height - 100.0).abs() < 0.01);
198    }
199
200    #[test]
201    fn square_aspect_ratio() {
202        let mut tree = WidgetTree::new();
203        let child = tree.add(FixedLeaf(50.0, 50.0));
204        let ar = tree.add(AspectRatio::square().child(child));
205        tree.layout(SizeProposal {
206            width: None,
207            height: Some(100.0),
208        });
209
210        let b = tree.bounds(ar);
211        assert!((b.width - 100.0).abs() < 0.01);
212        assert!((b.height - 100.0).abs() < 0.01);
213    }
214
215    #[test]
216    fn width_only_proposal() {
217        let ar = AspectRatio::new(4.0 / 3.0);
218        let theme = teksilo_core::presets::intui::light();
219        let ctx = LayoutContext::for_testing(&theme);
220        let size = ar
221            .layout_response(
222                SizeProposal {
223                    width: Some(400.0),
224                    height: None,
225                },
226                &ctx,
227            )
228            .size;
229        assert!((size.width - 400.0).abs() < 0.01);
230        assert!((size.height - 300.0).abs() < 0.01); // 400 / (4/3)
231    }
232}