use crate::render::palette::{BEST_FG, BEST_MODIFIER};
use crate::render::{Color, SpanStyle, StyledFrame, StyledLine, StyledSpan};
pub const GRID_WIDTH: usize = 40;
pub const GRID_HEIGHT: usize = 20;
const RAMP: [char; 5] = [' ', '░', '▒', '▓', '█'];
const COLOURS: [Color; 5] = [
Color::DarkGray,
Color::Blue,
Color::Cyan,
Color::Yellow,
Color::LightYellow,
];
fn sample<F: Fn(f64, f64) -> f64>(f: F, bounds: (f64, f64)) -> (Vec<f64>, f64, f64) {
let (lo, hi) = bounds;
let mut values = Vec::with_capacity(GRID_WIDTH * GRID_HEIGHT);
let mut fmin = f64::INFINITY;
let mut fmax = f64::NEG_INFINITY;
for row in 0..GRID_HEIGHT {
#[allow(clippy::cast_precision_loss)]
let ty = 1.0 - (row as f64 + 0.5) / GRID_HEIGHT as f64;
let y = lo + ty * (hi - lo);
for col in 0..GRID_WIDTH {
#[allow(clippy::cast_precision_loss)]
let tx = (col as f64 + 0.5) / GRID_WIDTH as f64;
let x = lo + tx * (hi - lo);
let v = f(x, y);
if v < fmin {
fmin = v;
}
if v > fmax {
fmax = v;
}
values.push(v);
}
}
(values, fmin, fmax)
}
fn quintile(v: f64, fmin: f64, fmax: f64) -> usize {
let span = fmax - fmin;
if span <= 0.0 {
return 0;
}
let t = ((v - fmin) / span).clamp(0.0, 1.0);
let idx = (t * 5.0).floor() as usize;
idx.min(4)
}
fn header_line(label: &str, bounds: (f64, f64), fmin: f64, fmax: f64) -> String {
format!(
"{label} bounds=[{:.2}, {:.2}] fmin={fmin:.3} fmax={fmax:.3}",
bounds.0, bounds.1
)
}
#[must_use]
pub fn render_landscape_ascii<F: Fn(f64, f64) -> f64>(
f: F,
bounds: (f64, f64),
label: &str,
) -> String {
let (values, fmin, fmax) = sample(&f, bounds);
let mut out = String::with_capacity(GRID_WIDTH * GRID_HEIGHT + 80);
out.push_str(&header_line(label, bounds, fmin, fmax));
out.push('\n');
for row in 0..GRID_HEIGHT {
for col in 0..GRID_WIDTH {
let v = values[row * GRID_WIDTH + col];
out.push(RAMP[quintile(v, fmin, fmax)]);
}
if row + 1 < GRID_HEIGHT {
out.push('\n');
}
}
out
}
#[must_use]
pub fn render_landscape_styled<F: Fn(f64, f64) -> f64>(
f: F,
bounds: (f64, f64),
label: &str,
) -> StyledFrame {
let (values, fmin, fmax) = sample(&f, bounds);
let mut lines: Vec<StyledLine> = Vec::with_capacity(GRID_HEIGHT + 1);
let label_style = SpanStyle::default()
.fg(BEST_FG)
.with_modifier(BEST_MODIFIER);
let header = header_line(label, bounds, fmin, fmax);
let header_line_spans = if let Some(rest) = header.strip_prefix(label) {
vec![
StyledSpan::new(label, label_style),
StyledSpan::raw(rest.to_string()),
]
} else {
vec![StyledSpan::raw(header)]
};
lines.push(StyledLine::from_spans(header_line_spans));
for row in 0..GRID_HEIGHT {
let mut spans: Vec<StyledSpan> = Vec::new();
let mut current_style = SpanStyle::default();
let mut current_text = String::with_capacity(GRID_WIDTH);
for col in 0..GRID_WIDTH {
let q = quintile(values[row * GRID_WIDTH + col], fmin, fmax);
let style = SpanStyle::default().fg(COLOURS[q]);
if style != current_style && !current_text.is_empty() {
spans.push(StyledSpan::new(
std::mem::take(&mut current_text),
current_style,
));
}
current_style = style;
current_text.push(RAMP[q]);
}
if !current_text.is_empty() {
spans.push(StyledSpan::new(current_text, current_style));
}
lines.push(StyledLine::from_spans(spans));
}
StyledFrame { lines }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sphere_like_surface_renders_within_budget() {
let frame = render_landscape_ascii(|x, y| x * x + y * y, (-5.0, 5.0), "Sphere");
for line in frame.lines() {
assert!(
line.chars().count() <= 80,
"line exceeds 80 cols: {line:?} ({} chars)",
line.chars().count()
);
}
assert_eq!(frame.lines().count(), 1 + GRID_HEIGHT);
}
#[test]
fn styled_matches_ascii_glyphs() {
let plain =
render_landscape_ascii(|x, y| x * x + y * y, (-5.0, 5.0), "Sphere");
let styled =
render_landscape_styled(|x, y| x * x + y * y, (-5.0, 5.0), "Sphere");
let plain_no_trailing: String = plain.lines().collect::<Vec<_>>().join("\n");
assert_eq!(styled.plain_text(), plain_no_trailing);
}
#[test]
fn quintile_endpoints_clamp() {
assert_eq!(quintile(0.0, 0.0, 1.0), 0);
assert_eq!(quintile(1.0, 0.0, 1.0), 4);
assert_eq!(quintile(0.5, 1.0, 1.0), 0);
}
#[test]
fn label_styled_with_best_palette() {
use crate::render::palette::{BEST_FG, BEST_MODIFIER};
let styled =
render_landscape_styled(|x, y| x * x + y * y, (-5.0, 5.0), "Sphere");
let header = &styled.lines[0];
let label = header
.spans
.iter()
.find(|s| s.text == "Sphere")
.expect("label span present");
assert_eq!(label.style.fg, Some(BEST_FG));
assert!(label.style.modifier.contains(BEST_MODIFIER));
}
}