superlighttui 0.23.0

Super Light TUI - A lightweight, ergonomic terminal UI library
Documentation
#[allow(unused_imports)]
use super::*;

#[derive(Debug, Clone)]
pub(crate) struct TickSpec {
    pub(crate) values: Vec<f64>,
    pub(crate) step: f64,
}

pub(super) fn build_ticks(min: f64, max: f64, target: usize) -> TickSpec {
    let (min, max) = normalize_bounds(min, max);
    let span = (max - min).abs().max(f64::EPSILON);
    let range = nice_number(span, false);
    let raw_step = range / (target.max(2) as f64 - 1.0);
    let step = nice_number(raw_step, true).max(f64::EPSILON);
    let nice_min = (min / step).floor() * step;
    let nice_max = (max / step).ceil() * step;

    let mut values = Vec::new();
    let mut value = nice_min;
    let limit = nice_max + step * 0.5;
    let mut guard = 0usize;
    while value <= limit && guard < 128 {
        values.push(value);
        value += step;
        guard = guard.saturating_add(1);
    }

    if values.is_empty() {
        values.push(min);
        values.push(max);
    }

    TickSpec { values, step }
}

/// TUI-aware tick generation: picks a nice step whose interval count
/// divides `cell_count - 1` as evenly as possible, with 3-8 intervals
/// and at least 2 rows per interval for readable spacing.
pub(super) fn build_tui_ticks(data_min: f64, data_max: f64, cell_count: usize) -> TickSpec {
    let (data_min, data_max) = normalize_bounds(data_min, data_max);
    // Very small plots: just show min/max boundaries
    if cell_count < 4 {
        let step = (data_max - data_min).abs().max(f64::EPSILON);
        return TickSpec {
            values: vec![data_min, data_max],
            step,
        };
    }

    let last = cell_count.saturating_sub(1).max(1);
    let span = (data_max - data_min).abs().max(f64::EPSILON);
    let log = span.log10().floor();

    // For small plots (< 15 rows), allow 1 row per interval instead of 2
    let min_spacing = if last >= 14 { 2 } else { 1 };

    let mut candidates: Vec<(f64, f64, usize, usize)> = Vec::new();

    for exp_off in -1..=1i32 {
        let base = 10.0_f64.powf(log + f64::from(exp_off));
        for &mult in &[1.0, 2.0, 2.5, 5.0] {
            let step = base * mult;
            if step <= 0.0 || !step.is_finite() {
                continue;
            }
            let lo = (data_min / step).floor() * step;
            let hi = (data_max / step).ceil() * step;
            let n = ((hi - lo) / step + 0.5) as usize;
            if (3..=8).contains(&n) && last / n >= min_spacing {
                let rem = last % n;
                candidates.push((step, lo, n, rem));
            }
        }
    }

    candidates.sort_by(|a, b| {
        a.3.cmp(&b.3).then_with(|| {
            let da = (a.2 as i32 - 5).unsigned_abs();
            let db = (b.2 as i32 - 5).unsigned_abs();
            da.cmp(&db)
        })
    });

    if let Some(&(step, lo, n, _)) = candidates.first() {
        let values: Vec<f64> = (0..=n).map(|i| lo + step * i as f64).collect();
        return TickSpec { values, step };
    }

    build_ticks(data_min, data_max, 5)
}

pub(super) fn nice_number(value: f64, round: bool) -> f64 {
    if value <= 0.0 || !value.is_finite() {
        return 1.0;
    }
    let exponent = value.log10().floor();
    let power = 10.0_f64.powf(exponent);
    let fraction = value / power;

    let nice_fraction = if round {
        if fraction < 1.5 {
            1.0
        } else if fraction < 3.0 {
            2.0
        } else if fraction < 7.0 {
            5.0
        } else {
            10.0
        }
    } else if fraction <= 1.0 {
        1.0
    } else if fraction <= 2.0 {
        2.0
    } else if fraction <= 5.0 {
        5.0
    } else {
        10.0
    };

    nice_fraction * power
}

pub(super) fn format_number(value: f64, step: f64) -> String {
    if !value.is_finite() {
        return "0".to_string();
    }
    let abs_step = step.abs().max(f64::EPSILON);
    let precision = if abs_step >= 1.0 {
        0
    } else {
        (-abs_step.log10().floor() as i32 + 1).clamp(0, 6) as usize
    };
    format!("{value:.precision$}")
}

pub(super) fn resolve_bounds<I>(values: I, manual: Option<(f64, f64)>) -> (f64, f64)
where
    I: Iterator<Item = f64>,
{
    if let Some((min, max)) = manual
        && min.is_finite()
        && max.is_finite()
    {
        return normalize_bounds(min, max);
    }

    let mut min = f64::INFINITY;
    let mut max = f64::NEG_INFINITY;
    for value in values {
        if !value.is_finite() {
            continue;
        }
        min = min.min(value);
        max = max.max(value);
    }

    if !min.is_finite() || !max.is_finite() {
        return (0.0, 1.0);
    }

    normalize_bounds(min, max)
}

pub(super) fn normalize_bounds(min: f64, max: f64) -> (f64, f64) {
    const MAX_SAFE_BOUND: f64 = f64::MAX / 16.0;

    if !min.is_finite() || !max.is_finite() {
        return (0.0, 1.0);
    }
    let (mut min, mut max) = if min <= max { (min, max) } else { (max, min) };
    min = min.clamp(-MAX_SAFE_BOUND, MAX_SAFE_BOUND);
    max = max.clamp(-MAX_SAFE_BOUND, MAX_SAFE_BOUND);

    if (max - min).abs() < f64::EPSILON {
        let pad = if min.abs() < 1.0 {
            1.0
        } else {
            min.abs() * 0.1
        };
        min = (min - pad).max(-MAX_SAFE_BOUND);
        max = (max + pad).min(MAX_SAFE_BOUND);
        if min == max {
            return (min - 1.0, max);
        }
    }

    (min, max)
}

pub(crate) fn finite_ratio(value: f64, min: f64, max: f64) -> Option<f64> {
    if !value.is_finite() || !min.is_finite() || !max.is_finite() {
        return None;
    }
    let (min, max) = normalize_bounds(min, max);
    let scale = value.abs().max(min.abs()).max(max.abs()).max(1.0);
    let scaled_min = min / scale;
    let scaled_span = max / scale - scaled_min;
    if !scaled_span.is_finite() || scaled_span.abs() < f64::EPSILON {
        return Some(0.5);
    }
    Some(((value / scale - scaled_min) / scaled_span).clamp(0.0, 1.0))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reversed_and_extreme_bounds_normalize_to_finite_ordered_span() {
        assert_eq!(normalize_bounds(10.0, -10.0), (-10.0, 10.0));
        let (min, max) = normalize_bounds(-f64::MAX, f64::MAX);
        assert!(min.is_finite() && max.is_finite());
        assert!(min < max);
        assert!((max - min).is_finite());
    }

    #[test]
    fn invalid_manual_bounds_fall_back_to_finite_data() {
        let values = [2.0, 4.0, f64::NAN];
        let bounds = resolve_bounds(values.into_iter(), Some((f64::NAN, f64::INFINITY)));
        assert_eq!(bounds, (2.0, 4.0));
    }

    #[test]
    fn finite_ratio_handles_extreme_spans_without_overflow() {
        assert_eq!(finite_ratio(-f64::MAX, -f64::MAX, f64::MAX), Some(0.0));
        assert_eq!(finite_ratio(0.0, -f64::MAX, f64::MAX), Some(0.5));
        assert_eq!(finite_ratio(f64::MAX, -f64::MAX, f64::MAX), Some(1.0));
        assert_eq!(finite_ratio(f64::NAN, 0.0, 1.0), None);
    }

    #[test]
    fn tick_generation_remains_finite_for_extreme_input() {
        let ticks = build_tui_ticks(-f64::MAX, f64::MAX, 40);
        assert!(!ticks.values.is_empty());
        assert!(ticks.step.is_finite());
        assert!(ticks.values.iter().all(|value| value.is_finite()));
    }
}