ferroui-pretext-layout 0.4.0

Platform-neutral wrap metrics and obstacle-aware column flow for prepared text
Documentation
use std::rc::Rc;

use ferroui_pretext::{
    DelegateTextMeasurerFactory, PrepareOptions, PreparedTextWithSegments, PretextLayout,
    WhiteSpaceMode,
};

use crate::*;

fn prepare(text: &str) -> Rc<PreparedTextWithSegments> {
    let engine = PretextLayout::new(Rc::new(DelegateTextMeasurerFactory::new(
        |text: &str, _: &str| text.chars().count() as f64 * 10.0,
    )));
    engine
        .prepare_with_segments(text, "16px Test", PrepareOptions::default())
        .unwrap()
}

fn close(actual: f64, expected: f64) {
    assert!(
        (actual - expected).abs() < 1e-8,
        "expected {expected}, got {actual}"
    );
}

#[test]
fn overlapping_unsorted_obstacles_leave_sorted_nonoverlapping_slots() {
    let slots = ObstacleLayoutHelper::carve_text_line_slots(
        Interval {
            left: 0.0,
            right: 100.0,
        },
        [
            Interval {
                left: 60.0,
                right: 80.0,
            },
            Interval {
                left: 20.0,
                right: 40.0,
            },
            Interval {
                left: 30.0,
                right: 70.0,
            },
        ],
        10.0,
    );
    assert_eq!(
        slots,
        [
            Interval {
                left: 0.0,
                right: 20.0
            },
            Interval {
                left: 80.0,
                right: 100.0
            }
        ]
    );
}

#[test]
fn blocked_slots_and_minimum_width_filtering_return_no_slot() {
    let base = Interval {
        left: 10.0,
        right: 90.0,
    };
    assert!(ObstacleLayoutHelper::carve_text_line_slots(
        base,
        [Interval {
            left: -10.0,
            right: 110.0
        }],
        0.0
    )
    .is_empty());
    assert!(ObstacleLayoutHelper::carve_text_line_slots(base, [], 81.0).is_empty());
    assert!(ObstacleLayoutHelper::pick_slot(&[], false).is_none());
}

#[test]
fn ties_choose_the_requested_edge_without_reordering_input() {
    let slots = [
        Interval {
            left: 80.0,
            right: 100.0,
        },
        Interval {
            left: 0.0,
            right: 20.0,
        },
    ];
    assert_eq!(
        ObstacleLayoutHelper::pick_slot(&slots, false),
        Some(slots[1])
    );
    assert_eq!(
        ObstacleLayoutHelper::pick_slot(&slots, true),
        Some(slots[0])
    );
}

#[test]
fn circle_band_uses_nearest_edge_not_midpoint() {
    let interval =
        ObstacleLayoutHelper::circle_interval_for_band(50.0, 9.0, 10.0, 0.0, 10.0, 2.0, 0.0)
            .unwrap();
    assert_eq!(
        interval,
        Interval {
            left: 38.0,
            right: 62.0
        }
    );
    let upper = ObstacleLayoutHelper::circle_interval_for_band(0.0, 0.0, 5.0, -6.0, -4.0, 0.0, 0.0)
        .unwrap();
    close(upper.width(), 6.0);
    assert!(
        ObstacleLayoutHelper::circle_interval_for_band(0.0, 0.0, 5.0, 5.0, 6.0, 0.0, 0.0).is_none()
    );
}

#[test]
fn ellipse_band_honors_horizontal_and_vertical_padding() {
    let interval =
        ObstacleLayoutHelper::ellipse_interval_for_band(10.0, 20.0, 8.0, 4.0, 24.0, 25.0, 2.0, 2.0)
            .unwrap();
    close(interval.width(), 16.0 * (0.75_f64).sqrt() + 4.0);
    assert!(ObstacleLayoutHelper::ellipse_interval_for_band(
        0.0, 0.0, 10.0, 0.0, 0.0, 10.0, 0.0, 0.0
    )
    .is_none());
}

#[test]
fn rectangle_bands_are_half_open_and_include_padding() {
    let rectangles = [RectObstacle {
        x: 10.0,
        y: 20.0,
        width: 30.0,
        height: 40.0,
    }];
    assert!(
        ObstacleLayoutHelper::get_rect_intervals_for_band(&rectangles, 0.0, 20.0, 0.0, 0.0)
            .is_empty()
    );
    assert!(
        ObstacleLayoutHelper::get_rect_intervals_for_band(&rectangles, 60.0, 70.0, 0.0, 0.0)
            .is_empty()
    );
    assert_eq!(
        ObstacleLayoutHelper::get_rect_intervals_for_band(&rectangles, 0.0, 20.0, 2.0, 1.0),
        [Interval {
            left: 8.0,
            right: 42.0
        }],
    );
}

#[test]
fn invalid_geometry_cannot_produce_nan_intervals() {
    assert!(ObstacleLayoutHelper::circle_interval_for_band(
        0.0,
        0.0,
        f64::NAN,
        0.0,
        10.0,
        0.0,
        0.0
    )
    .is_none());
    assert!(ObstacleLayoutHelper::carve_text_line_slots(
        Interval {
            left: 100.0,
            right: 0.0
        },
        [],
        0.0
    )
    .is_empty());
    assert!(ObstacleLayoutHelper::pick_slot(
        &[Interval {
            left: 0.0,
            right: f64::NAN
        }],
        false
    )
    .is_none());
}

#[test]
fn fully_blocked_first_row_does_not_consume_text() {
    let prepared = prepare("alpha beta");
    let columns = [RectObstacle {
        x: 20.0,
        y: 30.0,
        width: 100.0,
        height: 40.0,
    }];
    let obstacles = [RectObstacle {
        x: 20.0,
        y: 30.0,
        width: 100.0,
        height: 10.0,
    }];
    let lines =
        ColumnFlowLayout::layout_into_columns(&prepared, &columns, &obstacles, &[], 10.0, 1.0)
            .unwrap();
    assert_eq!(lines[0].x, 20.0);
    assert_eq!(lines[0].y, 40.0);
    assert_eq!(lines[0].text, "alpha beta");
}

#[test]
fn text_resumes_in_the_next_column() {
    let prepared = prepare("alpha beta gamma");
    let columns = [
        RectObstacle {
            x: 0.0,
            y: 10.0,
            width: 50.0,
            height: 10.0,
        },
        RectObstacle {
            x: 100.0,
            y: 20.0,
            width: 50.0,
            height: 20.0,
        },
    ];
    let lines =
        ColumnFlowLayout::layout_into_columns(&prepared, &columns, &[], &[], 10.0, 1.0).unwrap();
    assert_eq!(
        lines
            .iter()
            .map(|line| (line.text.as_str(), line.x, line.y))
            .collect::<Vec<_>>(),
        [
            ("alpha ", 0.0, 10.0),
            ("beta ", 100.0, 20.0),
            ("gamma", 100.0, 30.0),
        ]
    );
}

#[test]
fn column_flow_respects_full_circle_band() {
    let prepared = prepare("a b c");
    let columns = [RectObstacle {
        x: 0.0,
        y: 0.0,
        width: 100.0,
        height: 20.0,
    }];
    let circles = [CircleObstacle {
        x: 40.0,
        y: 9.0,
        radius: 20.0,
    }];
    let lines =
        ColumnFlowLayout::layout_into_columns(&prepared, &columns, &[], &circles, 10.0, 1.0)
            .unwrap();
    assert_eq!(lines[0].x, 60.0);
}

#[test]
fn invalid_line_height_and_nonprogressing_coordinates_are_rejected() {
    let prepared = prepare("text");
    let columns = [RectObstacle {
        x: 0.0,
        y: 0.0,
        width: 100.0,
        height: 100.0,
    }];
    for line_height in [0.0, -1.0, f64::NAN, f64::INFINITY] {
        assert_eq!(
            ColumnFlowLayout::layout_into_columns(&prepared, &columns, &[], &[], line_height, 1.0),
            Err(LayoutError::InvalidLineHeight)
        );
    }
    let columns = [RectObstacle {
        x: 0.0,
        y: 1e30,
        width: 100.0,
        height: 1e30,
    }];
    assert_eq!(
        ColumnFlowLayout::layout_into_columns(&prepared, &columns, &[], &[], 1.0, 1.0),
        Err(LayoutError::CoordinateOverflow)
    );
}

#[test]
fn impossible_columns_skip_without_iterating_tiny_line_heights() {
    let prepared = prepare("text");
    for width in [0.0, 10.0] {
        let columns = [RectObstacle {
            x: 0.0,
            y: 0.0,
            width,
            height: 1e300,
        }];
        let lines =
            ColumnFlowLayout::layout_into_columns(&prepared, &columns, &[], &[], 1e-300, 20.0)
                .unwrap();
        assert!(lines.is_empty());
    }
    let columns = [RectObstacle {
        x: 0.0,
        y: 0.0,
        width: 100.0,
        height: 1e300,
    }];
    let lines =
        ColumnFlowLayout::layout_into_columns(&prepared, &columns, &columns, &[], 1e-300, 20.0)
            .unwrap();
    assert!(lines.is_empty());
}

#[test]
fn natural_width_is_not_limited_to_one_hundred_thousand() {
    let prepared = prepare(&"a ".repeat(6000));
    assert_eq!(
        PreparedTextMetrics::measure_max_line_width(&prepared),
        119990.0
    );
}

#[test]
fn tight_wrap_matches_line_count_with_smaller_width() {
    let prepared = prepare("alpha beta gamma delta");
    let initial = PreparedTextMetrics::collect_wrap_metrics(&prepared, 125.0, 17.0);
    let tight = PreparedTextMetrics::find_tight_wrap_metrics(&prepared, 125.0, 17.0).unwrap();
    assert_eq!(tight.line_count, initial.line_count);
    assert_eq!(tight.height, initial.height);
    assert!(tight.max_line_width <= initial.max_line_width);
    assert_eq!(
        PreparedTextMetrics::find_tight_wrap_metrics(&prepared, f64::INFINITY, 17.0),
        Err(LayoutError::InvalidMaximumWidth)
    );
}

#[test]
fn max_line_width_respects_preserved_hard_breaks() {
    let engine = PretextLayout::new(Rc::new(DelegateTextMeasurerFactory::new(
        |text: &str, _: &str| text.chars().count() as f64 * 10.0,
    )));
    let prepared = engine
        .prepare_with_segments(
            "ab\nabcdef\n",
            "16px Test",
            PrepareOptions {
                white_space: WhiteSpaceMode::PreWrap,
                ..PrepareOptions::default()
            },
        )
        .unwrap();
    assert_eq!(PreparedTextMetrics::measure_max_line_width(&prepared), 60.0);
    let metrics = PreparedTextMetrics::collect_wrap_metrics(&prepared, 1000.0, 12.0);
    assert_eq!(
        metrics,
        WrapMetrics {
            line_count: 2,
            height: 24.0,
            max_line_width: 60.0
        }
    );
}