uzor-text 1.5.1

Pretext-pattern text layout engine for uzor — greedy word-wrap, rich multi-run spans, InlineBox, and a mixed-run baseline pass over the cosmic-text shaper (Arc 2 Phase 1+2; kinetics/ascii/hyphenation land in later phases).
//! ASCII cell-shader engine — a character grid where a per-cell program
//! `main(coord, ctx, cursor) -> Cell` runs every frame, like a GLSL fragment
//! shader applied to text cells.
//!
//! Ported from **ertdfgcvb/play.core** (the engine model: boot/pre/main/post,
//! SDF shapes → character density), with the glitch / hover-radius patterns from
//! **hsrambo07/hover-effects** (sine-glitch + stochastic swaps + cursor mask).
//! See `nemo/docs/mirage/research/pretext-canvas-text-engine.md`.
//!
//! Rendering: `AsciiGrid::step` runs the shader into a flat buffer; `render`
//! draws the buffer through any [`RenderContext`]. Shapes are signed-distance
//! fields (`sd_*`); chars are chosen by density (`density_char`); colour can
//! cycle via [`hsl`]. Any shape expressible as an SDF or a bitmap mask can be
//! filled with characters this way — letters (the menu "M"), the bloom, etc.
//!
//! Moved verbatim from `uzor` core (`uzor::ui::effects::text::cell_shader`)
//! as Arc 2 Phase 4's hard cutover
//! (`nemo/docs/uzor-engines/uzor_text_arc2_design.md` §4) — algorithm unchanged,
//! only the `RenderContext`/`TextAlign`/`TextBaseline` import moved from
//! `crate::render` (uzor core, in-crate) to `uzor::render` (this crate
//! consumes uzor core as a dependency). [`super::ParagraphAsciiShader`] is
//! the net-new piece this phase adds alongside this move.

use uzor::render::{RenderContext, TextAlign, TextBaseline};

/// Per-cell grid position.
#[derive(Clone, Copy)]
pub struct Coord {
    pub x: usize,
    pub y: usize,
    pub index: usize,
}

/// Per-frame grid + timing state handed to the shader.
#[derive(Clone, Copy)]
pub struct GridContext {
    pub frame: u64,
    /// Elapsed time in seconds.
    pub time: f64,
    pub cols: usize,
    pub rows: usize,
    /// Cell aspect = cell_width / cell_height (for circular SDFs).
    pub aspect: f64,
}

/// Pointer state in fractional grid cells + a host-fed interaction intensity.
#[derive(Clone, Copy, Default)]
pub struct Cursor {
    pub x: f64,
    pub y: f64,
    pub pressed: bool,
    pub inside: bool,
    /// 0 = idle, 1 = full interaction (hover eased + click pulse). The host
    /// sets this; shaders read it to drive glitch / colour.
    pub intensity: f64,
}

/// One rendered cell (the shader's output).
#[derive(Clone, Copy)]
pub struct Cell {
    pub ch: char,
    pub color: [u8; 3],
    pub alpha: f32,
    /// Glyph size as a fraction of the cell height (1.0 = fill). Lets density
    /// vary by SIZE as well as character, giving smooth texture transitions
    /// instead of hard same-size cells.
    pub scale: f32,
}

impl Default for Cell {
    fn default() -> Self {
        Self { ch: ' ', color: [244, 244, 245], alpha: 1.0, scale: 1.0 }
    }
}

/// A per-cell program. `main` is invoked once per cell per frame.
pub trait CellShader {
    fn main(&self, coord: Coord, ctx: &GridContext, cursor: &Cursor) -> Cell;
}

/// The grid + buffer that drives a [`CellShader`] and renders it.
pub struct AsciiGrid {
    cols: usize,
    rows: usize,
    buffer: Vec<Cell>,
    frame: u64,
    cursor: Cursor,
}

impl AsciiGrid {
    pub fn new(cols: usize, rows: usize) -> Self {
        Self {
            cols,
            rows,
            buffer: vec![Cell::default(); cols * rows],
            frame: 0,
            cursor: Cursor::default(),
        }
    }

    pub fn cols(&self) -> usize {
        self.cols
    }
    pub fn rows(&self) -> usize {
        self.rows
    }

    pub fn set_cursor(&mut self, cursor: Cursor) {
        self.cursor = cursor;
    }

    /// Run the shader over every cell for this frame.
    pub fn step<S: CellShader>(&mut self, shader: &S, time: f64, aspect: f64) {
        self.frame = self.frame.wrapping_add(1);
        let ctx = GridContext {
            frame: self.frame,
            time,
            cols: self.cols,
            rows: self.rows,
            aspect,
        };
        for y in 0..self.rows {
            for x in 0..self.cols {
                let index = y * self.cols + x;
                self.buffer[index] = shader.main(Coord { x, y, index }, &ctx, &self.cursor);
            }
        }
    }

    pub fn cell(&self, x: usize, y: usize) -> Cell {
        self.buffer[y * self.cols + x]
    }

    /// Visit every cell. This is the terminal hook: a TUI shell maps
    /// `(x, y, Cell)` onto its own buffer. `render` (RenderContext /
    /// fill_text) stays the pixel/proof path and is not used there.
    pub fn for_each<F>(&self, mut f: F)
    where
        F: FnMut(usize, usize, Cell),
    {
        for y in 0..self.rows {
            for x in 0..self.cols {
                f(x, y, self.cell(x, y));
            }
        }
    }

    /// Draw the buffer into a `cols*cell_w × rows*cell_h` box at `(ox, oy)`.
    ///
    /// Hot path — avoids per-cell heap allocs (stack utf8 buffer for the glyph,
    /// one reused colour string) and only flips global-alpha when it changes.
    pub fn render(&self, ctx: &mut dyn RenderContext, ox: f64, oy: f64, cell_w: f64, cell_h: f64) {
        use std::fmt::Write;
        // Per-cell glyphs are drawn centred and scaled by `cell.scale`, so the
        // field can fade by SIZE (small → large) for smooth texture transitions.
        ctx.set_text_align(TextAlign::Center);
        ctx.set_text_baseline(TextBaseline::Middle);

        let mut col = String::with_capacity(20);
        let mut chbuf = [0u8; 4];
        let mut cur_alpha = 1.0_f64;
        let mut cur_fs = -1_i32;
        ctx.set_global_alpha(1.0);
        let base = cell_h.max(4.0);
        for y in 0..self.rows {
            for x in 0..self.cols {
                let c = self.cell(x, y);
                if c.ch == ' ' || c.alpha <= 0.01 || c.scale <= 0.06 {
                    continue;
                }
                // Quantise font size to integer px so `set_font` only fires when
                // the size actually changes (adjacent cells usually match).
                let fs = ((base * c.scale as f64).clamp(3.0, base * 1.7)).round() as i32;
                if fs != cur_fs {
                    col.clear();
                    let _ = write!(col, "{}px monospace", fs);
                    ctx.set_font(&col);
                    cur_fs = fs;
                }
                let a = c.alpha as f64;
                if (a - cur_alpha).abs() > 0.004 {
                    ctx.set_global_alpha(a);
                    cur_alpha = a;
                }
                col.clear();
                let _ = write!(col, "rgb({},{},{})", c.color[0], c.color[1], c.color[2]);
                ctx.set_fill_color(&col);
                let s = c.ch.encode_utf8(&mut chbuf);
                ctx.fill_text(
                    s,
                    ox + x as f64 * cell_w + cell_w / 2.0,
                    oy + y as f64 * cell_h + cell_h / 2.0,
                );
            }
        }
        ctx.set_global_alpha(1.0);
    }
}

// ── SDF helpers (ports of play.core's sdf module) ───────────────────────────────

/// Signed distance to a circle of radius `r` centred at origin.
pub fn sd_circle(px: f64, py: f64, r: f64) -> f64 {
    (px * px + py * py).sqrt() - r
}

/// Signed distance to an axis-aligned box of half-extents `(bx, by)`.
pub fn sd_box(px: f64, py: f64, bx: f64, by: f64) -> f64 {
    let dx = px.abs() - bx;
    let dy = py.abs() - by;
    let ox = dx.max(0.0);
    let oy = dy.max(0.0);
    (ox * ox + oy * oy).sqrt() + dx.max(dy).min(0.0)
}

/// Density ramp, sparse → dense. Index by a 0..1 value.
pub const DENSITY: &[u8] = b" .:-=+*oOX#%@";

/// Pick a density character for a 0..1 value (0 = sparse, 1 = dense).
pub fn density_char(v: f64) -> char {
    let n = DENSITY.len();
    let i = ((v.clamp(0.0, 1.0)) * (n - 1) as f64).round() as usize;
    DENSITY[i.min(n - 1)] as char
}

// ── HSL → RGB (for iridescent cycling) ──────────────────────────────────────────

/// `h` in degrees, `s`/`l` in 0..1.
pub fn hsl(h: f64, s: f64, l: f64) -> [u8; 3] {
    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
    let hp = ((h % 360.0) + 360.0) % 360.0 / 60.0;
    let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
    let (r1, g1, b1) = match hp as i32 {
        0 => (c, x, 0.0),
        1 => (x, c, 0.0),
        2 => (0.0, c, x),
        3 => (0.0, x, c),
        4 => (x, 0.0, c),
        _ => (c, 0.0, x),
    };
    let m = l - c / 2.0;
    [
        (((r1 + m) * 255.0).clamp(0.0, 255.0)) as u8,
        (((g1 + m) * 255.0).clamp(0.0, 255.0)) as u8,
        (((b1 + m) * 255.0).clamp(0.0, 255.0)) as u8,
    ]
}

// ── GlitchLetter — a ready CellShader: an ASCII-art letter that glitches ─────────

/// A bitmap-mask glyph rendered through the cell-shader: on-cells show a steady
/// char at rest; under `cursor.intensity` they swap to glitch chars, spark, and
/// cycle an iridescent hue. `letter_m()` is a 5×7 "M".
pub struct GlitchLetter {
    pub cols: usize,
    pub rows: usize,
    mask: Vec<bool>,
    rest: char,
    glitch_chars: Vec<char>,
    base: [u8; 3],
}

impl GlitchLetter {
    pub fn from_rows(rows_src: &[&str], rest: char, base: [u8; 3]) -> Self {
        let rows = rows_src.len();
        let cols = rows_src.iter().map(|r| r.chars().count()).max().unwrap_or(0);
        let mut mask = vec![false; cols * rows];
        for (y, line) in rows_src.iter().enumerate() {
            for (x, c) in line.chars().enumerate() {
                mask[y * cols + x] = c == 'X';
            }
        }
        Self {
            cols,
            rows,
            mask,
            rest,
            glitch_chars: "#@%&$*/\\|=+<>?".chars().collect(),
            base,
        }
    }

    /// 5×7 capital "M" in amber.
    pub fn letter_m() -> Self {
        Self::from_rows(
            &["X...X", "XX.XX", "X.X.X", "X.X.X", "X...X", "X...X", "X...X"],
            '#',
            [251, 178, 106],
        )
    }
}

impl CellShader for GlitchLetter {
    fn main(&self, coord: Coord, ctx: &GridContext, cursor: &Cursor) -> Cell {
        let on = self.mask.get(coord.index).copied().unwrap_or(false);
        let it = cursor.intensity.clamp(0.0, 1.0);

        // A few off-cells spark on at higher intensity.
        let spark = !on && it > 0.15 && fastrand::f64() < 0.02 * it;
        if !on && !spark {
            return Cell { ch: ' ', color: self.base, alpha: 1.0, scale: 1.0 };
        }

        // Glyph: steady rest char, or a glitch swap with probability ∝ intensity.
        let swap = fastrand::f64() < 0.55 * it;
        let ch = if swap && !self.glitch_chars.is_empty() {
            self.glitch_chars[fastrand::usize(0..self.glitch_chars.len())]
        } else {
            self.rest
        };

        // Colour: amber at rest; iridescent hue cycle while glitching.
        let color = if it <= 0.001 {
            self.base
        } else {
            let hue = (ctx.time * 120.0 + coord.index as f64 * 16.0 + it * 200.0) % 360.0;
            let cyc = hsl(hue, 0.85, 0.62);
            let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * it) as u8;
            [mix(self.base[0], cyc[0]), mix(self.base[1], cyc[1]), mix(self.base[2], cyc[2])]
        };

        Cell { ch, color, alpha: if spark { 0.6 } else { 1.0 }, scale: 1.0 }
    }
}

#[cfg(test)]
mod tests {
    //! Headless proof: `GlitchLetter::letter_m()` at a fixed `fastrand` seed
    //! and a fixed cursor intensity/time (design law 8 — deterministic demos,
    //! no time/RNG driving the actual proof). `fastrand::seed` reseeds only
    //! this test's own OS thread's thread-local generator
    //! (`fastrand` 2.x global-RNG model), so this is reproducible regardless
    //! of what other tests run concurrently on other threads.

    use std::path::PathBuf;

    use uzor_export::{render_to_png, ExportSpec};

    use super::{AsciiGrid, Cursor, GlitchLetter};
    use crate::ascii::{draw_ascii_grid, AsciiGridStyle};

    const CELL: f64 = 24.0;
    const WIDTH: u32 = 200;
    const HEIGHT: u32 = 260;

    fn out_dir() -> PathBuf {
        // Fixed path — `uzor/out/` is the shared human-eyeball drop point
        // for every headless proof render in this workspace (matches
        // `crate::draw`'s own proof tests).
        PathBuf::from(r"C:\Users\VA PC\CODING\ML_TRADING\nemo\uzor\out")
    }

    fn write_proof_png(name: &str, bytes: &[u8]) {
        let dir = out_dir();
        std::fs::create_dir_all(&dir).expect("create uzor/out/ proof directory");
        std::fs::write(dir.join(name), bytes).expect("write proof PNG");
    }

    fn decoded_png_dims(bytes: &[u8]) -> (u32, u32) {
        let decoder = png::Decoder::new(bytes);
        let reader = decoder.read_info().expect("valid PNG header");
        let info = reader.info();
        (info.width, info.height)
    }

    #[test]
    fn glitch_letter_at_a_fixed_seed_and_intensity_renders_to_a_valid_png() {
        // Fixed seed + fixed cursor intensity + fixed time = 0.0: every
        // `fastrand` draw `GlitchLetter::main` makes is reproducible.
        fastrand::seed(20260716);

        let shader = GlitchLetter::letter_m();
        let mut grid = AsciiGrid::new(shader.cols, shader.rows);
        grid.set_cursor(Cursor { x: 2.0, y: 3.0, pressed: false, inside: true, intensity: 0.65 });
        grid.step(&shader, 0.0, 1.0);

        let spec = ExportSpec { width_px: WIDTH, height_px: HEIGHT, dpr: 1.0, background: Some([11, 11, 14, 255]) };
        let bytes = render_to_png(&spec, |ctx| {
            draw_ascii_grid(ctx, (10.0, 10.0), &grid, AsciiGridStyle::square(CELL));
        })
        .unwrap_or_else(|e| panic!("glitch letter proof render should succeed: {e}"));

        assert_eq!(decoded_png_dims(&bytes), (WIDTH, HEIGHT));
        write_proof_png("text_p4_glitch.png", &bytes);
    }
}