pinstar 1.1.1

Terminal-based canvas editor for .canvas files
Documentation
//! Adaptive canvas grid renderer (ported from clin's `ui::canvas_grid`).

use ratatui::{Frame, layout::Rect, style::Color};

/// Affine world-to-terminal projection used by [`draw_canvas_grid`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CanvasGridProjection {
    pub world_left: f64,
    pub world_right: f64,
    pub world_top: f64,
    pub world_bottom: f64,
    pub origin_col: f64,
    pub origin_row: f64,
    pub cols_per_world_x: f64,
    pub rows_per_world_y: f64,
}

impl CanvasGridProjection {
    fn is_valid(self) -> bool {
        [
            self.world_left,
            self.world_right,
            self.world_top,
            self.world_bottom,
            self.origin_col,
            self.origin_row,
            self.cols_per_world_x,
            self.rows_per_world_y,
        ]
        .iter()
        .all(|v| v.is_finite())
            && self.cols_per_world_x != 0.0
            && self.rows_per_world_y != 0.0
    }
}

/// Draw adaptive grid dots before view content so later view rendering replaces them.
pub fn draw_canvas_grid(
    frame: &mut Frame,
    area: Rect,
    visible: bool,
    projection: CanvasGridProjection,
    muted: Color,
    zoom: f64,
) {
    if !visible || area.is_empty() || !projection.is_valid() || !zoom.is_finite() || zoom <= 0.0 {
        return;
    }

    let min_x = projection.world_left.min(projection.world_right);
    let max_x = projection.world_left.max(projection.world_right);
    let min_y = projection.world_top.min(projection.world_bottom);
    let max_y = projection.world_top.max(projection.world_bottom);
    let mut grid_step_x: f64 = 100.0;
    let mut grid_step_y: f64 = 100.0;
    while grid_step_y * zoom < 6.0 {
        grid_step_x *= 2.0;
        grid_step_y *= 2.0;
    }
    // Compensate for terminal cell aspect ratio (~2:1 height:width) so grid appears square
    grid_step_y *= projection.cols_per_world_x.abs() / (2.0 * projection.rows_per_world_y.abs());
    let step_x = grid_step_x;
    let step_y = grid_step_y;
    if !step_x.is_finite() || !step_y.is_finite() || step_x == 0.0 || step_y == 0.0 {
        return;
    }

    let Some(start_x) = grid_index(min_x, step_x, f64::floor) else {
        return;
    };
    let Some(end_x) = grid_index(max_x, step_x, f64::ceil) else {
        return;
    };
    let Some(start_y) = grid_index(min_y, step_y, f64::floor) else {
        return;
    };
    let Some(end_y) = grid_index(max_y, step_y, f64::ceil) else {
        return;
    };

    let width = i64::from(area.width);
    let height = i64::from(area.height);
    let max_dots = width.saturating_mul(height).saturating_mul(4).max(1);
    let x_count = end_x.saturating_sub(start_x).saturating_add(1);
    let y_count = end_y.saturating_sub(start_y).saturating_add(1);
    if x_count.saturating_mul(y_count) > max_dots {
        return;
    }

    let left = f64::from(area.left());
    let right = f64::from(area.right());
    let top = f64::from(area.top());
    let bottom = f64::from(area.bottom());
    let buffer = frame.buffer_mut();
    for x_index in start_x..=end_x {
        let world_x = x_index as f64 * step_x;
        let col = projection.origin_col + world_x * projection.cols_per_world_x;
        if !col.is_finite() {
            continue;
        }
        let col = col.round();
        if col < left || col >= right {
            continue;
        }
        for y_index in start_y..=end_y {
            let world_y = y_index as f64 * step_y;
            let row = projection.origin_row + world_y * projection.rows_per_world_y;
            if !row.is_finite() {
                continue;
            }
            let row = row.round();
            if row < top || row >= bottom {
                continue;
            }
            if let Some(cell) = buffer.cell_mut((col as u16, row as u16))
                && (cell.symbol() == " " || cell.symbol() == "")
            {
                cell.set_char('·').set_fg(muted);
            }
        }
    }
}

fn grid_index(value: f64, step: f64, round: fn(f64) -> f64) -> Option<i64> {
    let index = round(value / step);
    (index >= i64::MIN as f64 && index <= i64::MAX as f64).then_some(index as i64)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::{Terminal, backend::TestBackend};

    fn projection(
        cols_per_world_x: f64,
        rows_per_world_y: f64,
        step_x: f64,
        step_y: f64,
    ) -> CanvasGridProjection {
        let (world_top, world_bottom) = if rows_per_world_y.is_sign_negative() {
            (-step_y, 0.0)
        } else {
            (0.0, step_y)
        };
        CanvasGridProjection {
            world_left: 0.0,
            world_right: step_x,
            world_top,
            world_bottom,
            origin_col: 0.0,
            origin_row: 0.0,
            cols_per_world_x,
            rows_per_world_y,
        }
    }

    fn render_grid(
        area: Rect,
        visible: bool,
        projection: CanvasGridProjection,
        zoom: f64,
    ) -> ratatui::buffer::Buffer {
        let mut terminal = Terminal::new(TestBackend::new(24, 16)).unwrap();
        terminal
            .draw(|frame| draw_canvas_grid(frame, area, visible, projection, Color::DarkGray, zoom))
            .unwrap();
        terminal.backend().buffer().clone()
    }

    #[test]
    fn grid_dots_appear_on_empty_cells_only() {
        let p = projection(1.0, 1.0, 1.0, 1.0);
        let buf = render_grid(Rect::new(0, 0, 24, 16), true, p, 1.0);
        let dots = buf.content.iter().filter(|c| c.symbol() == "·").count();
        assert!(dots > 0, "expected grid dots");
    }

    #[test]
    fn hidden_grid_draws_nothing() {
        let p = projection(1.0, 1.0, 1.0, 1.0);
        let buf = render_grid(Rect::new(0, 0, 24, 16), false, p, 1.0);
        assert!(buf.content.iter().all(|c| c.symbol() == " "));
    }

    #[test]
    fn degenerate_projection_is_noop() {
        let p = CanvasGridProjection {
            world_left: 0.0,
            world_right: 1.0,
            world_top: 0.0,
            world_bottom: 1.0,
            origin_col: f64::NAN,
            origin_row: 0.0,
            cols_per_world_x: 1.0,
            rows_per_world_y: 1.0,
        };
        let buf = render_grid(Rect::new(0, 0, 24, 16), true, p, 1.0);
        assert!(buf.content.iter().all(|c| c.symbol() == " "));
    }
}