ansiq-render 0.1.0

Framebuffer, diff generation, and terminal patch emission for Ansiq.
Documentation
use std::io::{self, Write};

use ansiq_core::{
    Color, HistoryBlock, HistoryEntry, HistoryLine, HistoryRun, Style, history_block_from_text,
};
use crossterm::{
    cursor::{Hide, MoveTo, Show},
    queue,
    style::{
        Attribute, Color as CrosstermColor, Print, SetAttribute, SetBackgroundColor,
        SetForegroundColor,
    },
};
use unicode_width::UnicodeWidthChar;

use crate::{Cell, FrameBuffer};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Patch {
    pub x: u16,
    pub y: u16,
    pub text: String,
    pub style: Style,
}

pub fn diff_buffers(prev: &FrameBuffer, next: &FrameBuffer) -> Vec<Patch> {
    let width = prev.width().max(next.width());
    let height = prev.height().max(next.height());
    let mut patches = Vec::new();

    for y in 0..height {
        let mut x = 0;
        while x < width {
            let prev_cell = read_cell(prev, x, y);
            let next_cell = read_cell(next, x, y);
            if prev_cell == next_cell {
                x += 1;
                continue;
            }

            let style = next_cell.style;
            let start = x;
            let mut text = String::new();

            while x < width {
                let prev_cell = read_cell(prev, x, y);
                let next_cell = read_cell(next, x, y);
                if prev_cell == next_cell || next_cell.style != style {
                    break;
                }

                text.push(next_cell.symbol);
                x += 1;
            }

            patches.push(Patch {
                x: start,
                y,
                text,
                style,
            });
        }
    }

    patches
}

pub fn frame_patches(frame: &FrameBuffer) -> Vec<Patch> {
    let mut patches = Vec::with_capacity(frame.height() as usize);

    for y in 0..frame.height() {
        let mut x = 0;
        while x < frame.width() {
            let first = frame.get(x, y);
            let style = first.style;
            let start = x;
            let mut text = String::new();

            while x < frame.width() {
                let cell = frame.get(x, y);
                if cell.style != style {
                    break;
                }
                text.push(cell.symbol);
                x += 1;
            }

            patches.push(Patch {
                x: start,
                y,
                text,
                style,
            });
        }
    }

    patches
}

pub fn diff_buffers_in_regions(
    prev: &FrameBuffer,
    next: &FrameBuffer,
    regions: &[ansiq_core::Rect],
) -> Vec<Patch> {
    let mut patches = Vec::new();

    for region in regions {
        if region.is_empty() {
            continue;
        }

        let max_y = region.bottom().min(prev.height().max(next.height()));
        let max_x = region.right().min(prev.width().max(next.width()));

        for y in region.y..max_y {
            let mut x = region.x;
            while x < max_x {
                let prev_cell = read_cell(prev, x, y);
                let next_cell = read_cell(next, x, y);
                if prev_cell == next_cell {
                    x += 1;
                    continue;
                }

                let style = next_cell.style;
                let start = x;
                let mut text = String::new();

                while x < max_x {
                    let prev_cell = read_cell(prev, x, y);
                    let next_cell = read_cell(next, x, y);
                    if prev_cell == next_cell || next_cell.style != style {
                        break;
                    }

                    text.push(next_cell.symbol);
                    x += 1;
                }

                patches.push(Patch {
                    x: start,
                    y,
                    text,
                    style,
                });
            }
        }
    }

    patches
}

pub fn render_patches<W: Write>(writer: &mut W, patches: &[Patch]) -> io::Result<()> {
    render_patches_at_origin(writer, patches, 0)
}

pub fn render_patches_at_origin<W: Write>(
    writer: &mut W,
    patches: &[Patch],
    origin_y: u16,
) -> io::Result<()> {
    for patch in patches {
        let text = collapse_wide_continuations(&patch.text);
        queue!(
            writer,
            MoveTo(patch.x, patch.y.saturating_add(origin_y)),
            SetForegroundColor(map_color(patch.style.fg)),
            SetBackgroundColor(map_color(patch.style.bg)),
            SetAttribute(if patch.style.bold {
                Attribute::Bold
            } else {
                Attribute::NormalIntensity
            }),
            SetAttribute(if patch.style.reversed {
                Attribute::Reverse
            } else {
                Attribute::NoReverse
            }),
            Print(text)
        )?;
    }

    queue!(
        writer,
        SetForegroundColor(CrosstermColor::Reset),
        SetBackgroundColor(CrosstermColor::Reset),
        SetAttribute(Attribute::Reset)
    )?;

    writer.flush()
}

pub fn render_cursor<W: Write>(writer: &mut W, cursor: Option<(u16, u16)>) -> io::Result<()> {
    render_cursor_at_origin(writer, cursor, 0)
}

pub fn render_cursor_at_origin<W: Write>(
    writer: &mut W,
    cursor: Option<(u16, u16)>,
    origin_y: u16,
) -> io::Result<()> {
    match cursor {
        Some((x, y)) => {
            queue!(writer, Show, MoveTo(x, y.saturating_add(origin_y)))?;
        }
        None => {
            queue!(writer, Hide)?;
        }
    }

    writer.flush()
}

pub fn history_block_from_buffer(buffer: &FrameBuffer) -> HistoryBlock {
    let mut lines = Vec::with_capacity(buffer.height() as usize);

    for y in 0..buffer.height() {
        let mut last_visible_x = None;
        for x in 0..buffer.width() {
            if buffer.get(x, y).symbol != ' ' {
                last_visible_x = Some(x);
            }
        }

        let Some(end_x) = last_visible_x.map(|x| x + 1) else {
            lines.push(HistoryLine { runs: Vec::new() });
            continue;
        };

        let mut runs = Vec::new();
        let mut current_style = buffer.get(0, y).style;
        let mut current_text = String::new();

        for x in 0..end_x {
            let cell = buffer.get(x, y);
            if x == 0 {
                current_style = cell.style;
            }
            if cell.style != current_style {
                push_history_run(&mut runs, &mut current_text, current_style);
                current_style = cell.style;
            }
            current_text.push(cell.symbol);
        }

        push_history_run(&mut runs, &mut current_text, current_style);
        lines.push(HistoryLine { runs });
    }

    HistoryBlock { lines }
}

pub fn render_history_entries<W: Write>(
    writer: &mut W,
    entries: &[HistoryEntry],
    width: u16,
) -> io::Result<u16> {
    let mut wrote_any_line = false;
    let mut rendered_rows = 0u16;

    for (entry_index, entry) in entries.iter().enumerate() {
        let owned_block;
        let block = match entry {
            HistoryEntry::Text(content) => {
                owned_block = history_block_from_text(content, width);
                &owned_block
            }
            HistoryEntry::Block(block) => block,
        };

        if entry_index > 0 && wrote_any_line {
            write!(writer, "\r\n")?;
            rendered_rows = rendered_rows.saturating_add(1);
        }

        for (line_index, line) in block.lines.iter().enumerate() {
            if wrote_any_line || line_index > 0 {
                write!(writer, "\r\n")?;
            }
            render_history_line(writer, line)?;
            wrote_any_line = true;
            rendered_rows = rendered_rows.saturating_add(1);
        }
    }

    if rendered_rows > 0 {
        write!(writer, "\r\n")?;
    }
    queue!(
        writer,
        SetForegroundColor(CrosstermColor::Reset),
        SetBackgroundColor(CrosstermColor::Reset),
        SetAttribute(Attribute::Reset)
    )?;
    writer.flush()?;
    Ok(rendered_rows)
}

fn collapse_wide_continuations(text: &str) -> String {
    let mut collapsed = String::new();
    let mut skip = 0u16;

    for ch in text.chars() {
        if skip > 0 {
            skip -= 1;
            continue;
        }

        collapsed.push(ch);
        let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
        if width > 1 {
            skip = width - 1;
        }
    }

    collapsed
}

fn push_history_run(runs: &mut Vec<HistoryRun>, text: &mut String, style: Style) {
    if text.is_empty() {
        return;
    }

    runs.push(HistoryRun {
        text: collapse_wide_continuations(text),
        style,
    });
    text.clear();
}

fn render_history_line<W: Write>(writer: &mut W, line: &HistoryLine) -> io::Result<()> {
    if line.runs.is_empty() {
        return Ok(());
    }

    for run in &line.runs {
        queue!(
            writer,
            SetForegroundColor(map_color(run.style.fg)),
            SetBackgroundColor(map_color(run.style.bg)),
            SetAttribute(if run.style.bold {
                Attribute::Bold
            } else {
                Attribute::NormalIntensity
            }),
            SetAttribute(if run.style.reversed {
                Attribute::Reverse
            } else {
                Attribute::NoReverse
            }),
            Print(&run.text)
        )?;
    }

    Ok(())
}
fn read_cell(buffer: &FrameBuffer, x: u16, y: u16) -> Cell {
    if x >= buffer.width() || y >= buffer.height() {
        Cell::default()
    } else {
        buffer.get(x, y)
    }
}

fn map_color(color: Color) -> CrosstermColor {
    match color {
        Color::Reset => CrosstermColor::Reset,
        Color::Black => CrosstermColor::Black,
        Color::DarkGrey => CrosstermColor::DarkGrey,
        Color::Grey => CrosstermColor::Grey,
        Color::White => CrosstermColor::White,
        Color::Blue => CrosstermColor::Blue,
        Color::Cyan => CrosstermColor::Cyan,
        Color::Green => CrosstermColor::Green,
        Color::Yellow => CrosstermColor::Yellow,
        Color::Magenta => CrosstermColor::Magenta,
        Color::Red => CrosstermColor::Red,
        Color::Indexed(index) => CrosstermColor::AnsiValue(index),
        Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
    }
}