rust_widgets 2.9.3

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Aspect ratio layout manager — constrains a child to a specific aspect ratio.
//!
//! The child is sized to fit within the parent while maintaining the given
//! aspect ratio (width / height). When `respect_parent` is true, the child's
//! size is bounded by the parent dimensions so it never exceeds them.
use super::{ChildInfo, Layout, LayoutContext};
use crate::compat::{Any, Vec};
use crate::core::{ObjectId, Rect};

/// How a child is fitted to the available box while preserving its aspect ratio.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AspectMode {
    /// Fit the whole child inside the parent (letterboxing).
    Contain,
    /// Fill the parent and crop the overflowing axis (cover).
    Cover,
    /// Take the parent's width and derive the height, even when it overflows.
    Stretch,
}

/// A layout that constrains a single child to a specific aspect ratio.
///
/// The aspect ratio is defined as `width / height`. The child is sized to
/// fit within the parent while maintaining this ratio. When `respect_parent`
/// is true, the child never exceeds the parent's dimensions.
#[derive(Debug)]
pub struct AspectRatioLayout {
    child: Option<ObjectId>,
    /// Desired aspect ratio (width / height).
    aspect_ratio: f32,
    /// If true, the child is bounded by the parent rect on both axes.
    respect_parent: bool,
    /// Explicit fit mode. `None` keeps the historical `respect_parent` meaning, so an
    /// existing caller's pixels do not move; `Some` opts into cover/stretch/contain.
    mode: Option<AspectMode>,
}

impl AspectRatioLayout {
    /// Create a new aspect ratio layout.
    ///
    /// # Panics
    ///
    /// Panics if `aspect_ratio` is not a finite positive number. A `NaN` or an
    /// infinity is not a ratio the child can have: an infinite ratio would make
    /// `parent_w / ratio` zero and collapse the child to nothing even in a
    /// non-empty parent (D08-L-02, Round 55).
    pub fn new(aspect_ratio: f32, respect_parent: bool) -> Self {
        assert!(
            aspect_ratio.is_finite() && aspect_ratio > 0.0,
            "AspectRatioLayout: aspect_ratio must be finite and positive"
        );
        Self { child: None, aspect_ratio, respect_parent, mode: None }
    }

    /// Set the aspect ratio (width / height).
    ///
    /// # Panics
    ///
    /// Panics if `ratio` is not a finite positive number (see [`AspectRatioLayout::new`]).
    pub fn set_aspect_ratio(&mut self, ratio: f32) {
        assert!(
            ratio.is_finite() && ratio > 0.0,
            "AspectRatioLayout: aspect_ratio must be finite and positive"
        );
        self.aspect_ratio = ratio;
    }

    /// Returns the current aspect ratio.
    pub fn aspect_ratio(&self) -> f32 {
        self.aspect_ratio
    }

    /// Set whether the child is bounded by parent dimensions.
    ///
    /// This also clears any explicit [`AspectMode`], restoring the constructor's meaning.
    pub fn set_respect_parent(&mut self, respect: bool) {
        self.respect_parent = respect;
        self.mode = None;
    }

    /// Returns whether the child is bounded by parent dimensions.
    pub fn respect_parent(&self) -> bool {
        self.respect_parent
    }

    /// Sets an explicit fit mode; see [`AspectMode`].
    ///
    /// `Contain` keeps the child inside the parent, `Cover` fills it while cropping, and
    /// `Stretch` derives the height from the parent's width. The previous
    /// `respect_parent` flag remains the fallback until this is called.
    pub fn set_mode(&mut self, mode: AspectMode) {
        self.mode = Some(mode);
        self.respect_parent = matches!(mode, AspectMode::Contain);
    }

    /// The explicit fit mode, or `None` when `respect_parent` selects the behaviour.
    pub fn mode(&self) -> Option<AspectMode> {
        self.mode
    }

    /// Compute the child rect that fits within the given rect while maintaining the aspect ratio.
    ///
    /// A positive extent that rounds to a sub-pixel value keeps at least one pixel, so a
    /// child that has real width or height does not silently disappear (principle #50).
    /// A zero extent (an empty parent, or a degenerate axis) stays zero.
    fn compute_child_rect(&self, parent: Rect) -> Rect {
        let parent_w = parent.width as f32;
        let parent_h = parent.height as f32;
        let ratio = self.aspect_ratio;

        // Round a non-negative extent to pixels, keeping a positive-but-sub-pixel extent
        // visible as one pixel rather than collapsing it to zero.
        fn to_px(extent: f32) -> u32 {
            if extent <= 0.0 {
                return 0;
            }
            let rounded = extent.round();
            if rounded >= 1.0 {
                rounded as u32
            } else {
                // A positive extent below one pixel (including one that rounds down to 0)
                // keeps a single visible pixel.
                1
            }
        }

        let (child_w, child_h) = if self.mode == Some(AspectMode::Cover) {
            // Cover keeps the ratio and fills both axes; the overflowing axis is
            // deliberately allowed past the parent and is cropped by the painter.
            if parent_w / ratio >= parent_h {
                (to_px(parent_w), to_px(parent_w / ratio))
            } else {
                (to_px(parent_h * ratio), to_px(parent_h))
            }
        } else if !self.effective_contain() {
            // Allow child to exceed parent if necessary to maintain ratio.
            let w = parent_w;
            (to_px(w), to_px(w / ratio))
        } else {
            let by_width = (parent_w, parent_w / ratio);
            let by_height = (parent_h * ratio, parent_h);
            if by_width.1 <= parent_h {
                (to_px(by_width.0), to_px(by_width.1))
            } else {
                (to_px(by_height.0), to_px(by_height.1))
            }
        };

        // Round the centring offsets symmetrically. Integer division truncated the two
        // two-pixel differences in opposite directions (1 px at the start, 1 px at the
        // end), so an odd leftover visibly favoured one edge; `round` splits it evenly
        // and makes the pair add up to the leftover.
        let x_offset = (((parent.width as i32) - (child_w as i32)) as f32 / 2.0).round() as i32;
        let y_offset = (((parent.height as i32) - (child_h as i32)) as f32 / 2.0).round() as i32;

        Rect::new(parent.x + x_offset, parent.y + y_offset, child_w, child_h)
    }

    /// Whether the historical containment behaviour applies.
    fn effective_contain(&self) -> bool {
        self.mode.map(|mode| mode == AspectMode::Contain).unwrap_or(self.respect_parent)
    }
}

impl Layout for AspectRatioLayout {
    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }

    fn add_widget(&mut self, widget_id: ObjectId, _stretch: u32) {
        self.child = Some(widget_id);
    }

    fn remove_widget(&mut self, widget_id: ObjectId) {
        if self.child == Some(widget_id) {
            self.child = None;
        }
    }

    fn child_ids(&self) -> Vec<ObjectId> {
        self.child.into_iter().collect()
    }

    fn has_child(&self, id: ObjectId) -> bool {
        self.child == Some(id)
    }

    fn clear(&mut self) {
        self.child = None;
    }

    fn update(&self, rect: Rect, widgets: &mut dyn FnMut(ObjectId, Rect)) {
        if let Some(child_id) = self.child {
            let child_rect = self.compute_child_rect(rect);
            widgets(child_id, child_rect);
        }
    }

    // G-403: the trait no longer defaults these entries; each forward reproduces the previous fallback behaviour.
    fn update_with_context(
        &self,
        rect: Rect,
        context: &LayoutContext,
        widgets: &mut dyn FnMut(ObjectId, Rect),
    ) {
        let _ = context;
        self.update(rect, widgets);
    }

    fn arrange(&self, rect: Rect, children: &[ChildInfo], widgets: &mut dyn FnMut(ObjectId, Rect)) {
        let _ = children;
        self.update(rect, widgets);
    }

    fn arrange_with_context(
        &self,
        rect: Rect,
        children: &[ChildInfo],
        context: &LayoutContext,
        widgets: &mut dyn FnMut(ObjectId, Rect),
    ) {
        let _ = context;
        self.arrange(rect, children, widgets);
    }
}

#[cfg(test)]
// A later impl block follows the tests (layout helpers kept next to their users); the
// lint is suppressed here for that file-shape reason, as in demo/control/src/app.rs.
#[allow(clippy::items_after_test_module)]
mod tests {
    use super::*;

    #[test]
    fn test_aspect_ratio_fit_width() {
        // aspect_ratio = 2.0 (width/height), parent is 200x100.
        // with respect_parent=true.
        // By width: 200x100 -> 200/2=100 height, fits (100 <= 100).
        let layout = AspectRatioLayout::new_with_child(42, 2.0, true);

        let mut out = None;
        layout.update(Rect::new(0, 0, 200, 100), &mut |id, rect| {
            if id == 42 {
                out = Some(rect);
            }
        });

        let rect = out.expect("child should be positioned");
        assert_eq!(rect.width, 200);
        assert_eq!(rect.height, 100);
    }

    #[test]
    fn test_aspect_ratio_fit_height() {
        // aspect_ratio = 1.0 (square), parent is 200x50 (wide).
        // with respect_parent=true.
        // By width: 200x200 -> 200 height exceeds 50, so use by_height: 50x50.
        let layout = AspectRatioLayout::new_with_child(42, 1.0, true);

        let mut out = None;
        layout.update(Rect::new(0, 0, 200, 50), &mut |id, rect| {
            if id == 42 {
                out = Some(rect);
            }
        });

        // by height: height=50, width=50*1.0=50 -> 50x50
        let rect = out.expect("child should be positioned");
        assert_eq!(rect.width, 50);
        assert_eq!(rect.height, 50);
        // centering: x = (200-50)/2 = 75, y = 0
        assert_eq!(rect.x, 75);
        assert_eq!(rect.y, 0);
    }

    #[test]
    fn test_aspect_ratio_does_not_respect_parent() {
        // aspect_ratio = 1.0, parent is 100x100.
        // With respect_parent=false, child matches parent exactly.
        let layout = AspectRatioLayout::new_with_child(1, 1.0, false);

        let mut out = None;
        layout.update(Rect::new(0, 0, 100, 100), &mut |id, rect| {
            if id == 1 {
                out = Some(rect);
            }
        });

        let rect = out.expect("child should be positioned");
        assert_eq!(rect.width, 100);
        assert_eq!(rect.height, 100);
    }

    #[test]
    fn test_aspect_ratio_remove_and_clear() {
        let mut layout = AspectRatioLayout::new(2.0, true);
        layout.add_widget(10, 0);
        assert!(layout.has_child(10));

        layout.remove_widget(10);
        assert!(!layout.has_child(10));

        layout.add_widget(20, 0);
        layout.clear();
        assert!(layout.child_ids().is_empty());
    }

    /// D08-L-02: a positive sub-pixel extent must stay visible, not round to zero.
    #[test]
    fn a_positive_sub_pixel_extent_keeps_one_pixel() {
        // ratio 4.0, non-empty 1x1 parent: by_width is (1, 0.25), which fits, and the
        // 0.25 height must become 1 rather than 0.
        let layout = AspectRatioLayout::new_with_child(42, 4.0, true);
        let mut out = None;
        layout.update(Rect::new(0, 0, 1, 1), &mut |id, rect| {
            if id == 42 {
                out = Some(rect);
            }
        });
        let rect = out.expect("child should be positioned");
        assert_eq!(rect.width, 1);
        assert_eq!(rect.height, 1, "a positive extent must not collapse to zero");
    }

    /// An empty parent still yields a degenerate (zero) child rather than a 1x1 one.
    #[test]
    fn an_empty_parent_still_yields_a_zero_sized_child() {
        let layout = AspectRatioLayout::new_with_child(42, 2.0, true);
        let mut out = None;
        layout.update(Rect::new(0, 0, 0, 0), &mut |id, rect| {
            if id == 42 {
                out = Some(rect);
            }
        });
        let rect = out.expect("child should be positioned");
        assert_eq!((rect.width, rect.height), (0, 0));
    }

    /// Cover fills both axes while preserving the ratio, and the centring offset is
    /// rounded symmetrically for an odd leftover.
    #[test]
    fn cover_mode_fills_and_centring_rounds_symmetrically() {
        let mut layout = AspectRatioLayout::new_with_child(7, 1.0, true);
        layout.set_mode(AspectMode::Cover);
        let mut out = None;
        layout.update(Rect::new(0, 0, 100, 50), &mut |_, rect| out = Some(rect));
        let rect = out.expect("the child is placed");
        assert_eq!((rect.width, rect.height), (100, 100), "cover fills both axes");
        assert_eq!(rect.y, -25, "the cropped axis is centred around the parent");

        // A 5 px horizontal leftover splits 2 px before and 3 px after (round half up),
        // rather than truncating both to 2.
        let mut square = AspectRatioLayout::new_with_child(8, 1.0, true);
        square.set_mode(AspectMode::Contain);
        let mut out = None;
        square.update(Rect::new(0, 0, 205, 50), &mut |_, rect| out = Some(rect));
        let rect = out.expect("the child is placed");
        assert_eq!(rect.width, 50);
        assert_eq!(rect.x, 78, "an odd leftover must be split evenly, not truncated twice");
    }

    /// D08-L-02: a non-finite ratio is rejected at every entry point, so it can never
    /// produce a zero-height child in a non-empty parent.
    #[test]
    fn non_finite_ratios_are_rejected() {
        assert!(std::panic::catch_unwind(|| AspectRatioLayout::new(f32::INFINITY, true)).is_err());
        assert!(std::panic::catch_unwind(|| AspectRatioLayout::new(f32::NAN, true)).is_err());
        assert!(std::panic::catch_unwind(|| AspectRatioLayout::new(0.0, true)).is_err());
        assert!(std::panic::catch_unwind(|| AspectRatioLayout::new(-1.0, true)).is_err());
        let mut layout = AspectRatioLayout::new(2.0, true);
        assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
            layout.set_aspect_ratio(f32::INFINITY)
        }))
        .is_err());
    }
}

/// Helper for constructing a layout pre-populated with a child.
#[cfg(test)]
impl AspectRatioLayout {
    /// Create a new aspect ratio layout with an initial child (test-only helper).
    ///
    /// # Panics
    ///
    /// Panics if `aspect_ratio` is not a finite positive number.
    pub fn new_with_child(child_id: ObjectId, aspect_ratio: f32, respect_parent: bool) -> Self {
        assert!(
            aspect_ratio.is_finite() && aspect_ratio > 0.0,
            "AspectRatioLayout: aspect_ratio must be finite and positive"
        );
        Self { child: Some(child_id), aspect_ratio, respect_parent, mode: None }
    }
}