mdlite 0.2.1

A super-lightweight terminal Markdown reader
Documentation
use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader, IsTerminal, Write};

const CLEAR_SCREEN: &str = "\x1b[2J\x1b[H";
const CLEAR_LINE: &str = "\r\x1b[2K";

pub fn run_pager(rendered: &str) -> Result<(), String> {
    let stdout = io::stdout();
    if !stdout.is_terminal() {
        print!("{rendered}");
        return Ok(());
    }

    let mut tty = open_tty_input()?;
    let mut stdout = stdout.lock();
    let lines: Vec<&str> = rendered.split_inclusive('\n').collect();
    let page_height = page_height();

    if lines.len() <= page_height {
        print!("{rendered}");
        return Ok(());
    }

    let mut start = 0;
    loop {
        let end = (start + page_height).min(lines.len());
        stdout
            .write_all(CLEAR_SCREEN.as_bytes())
            .map_err(|error| format!("failed to clear screen: {error}"))?;
        for line in &lines[start..end] {
            stdout
                .write_all(line.as_bytes())
                .map_err(|error| format!("failed to write page: {error}"))?;
        }

        if end >= lines.len() {
            stdout
                .write_all(CLEAR_LINE.as_bytes())
                .map_err(|error| format!("failed to clear prompt: {error}"))?;
            stdout
                .flush()
                .map_err(|error| format!("failed to flush output: {error}"))?;
            break;
        }

        stdout
            .write_all(b"\r--More-- [Enter next, b back, q quit] ")
            .map_err(|error| format!("failed to write prompt: {error}"))?;
        stdout
            .flush()
            .map_err(|error| format!("failed to flush output: {error}"))?;

        match read_command(&mut tty)? {
            PagerCommand::Next => start = end,
            PagerCommand::Back => start = start.saturating_sub(page_height),
            PagerCommand::Quit => {
                stdout
                    .write_all(CLEAR_LINE.as_bytes())
                    .map_err(|error| format!("failed to clear prompt: {error}"))?;
                stdout
                    .flush()
                    .map_err(|error| format!("failed to flush output: {error}"))?;
                break;
            }
        }
    }

    Ok(())
}

fn open_tty_input() -> Result<BufReader<File>, String> {
    for path in ["/dev/tty", "CONIN$"] {
        if let Ok(file) = File::open(path) {
            return Ok(BufReader::new(file));
        }
    }

    Err("pager input requires an interactive terminal".to_string())
}

fn read_command(reader: &mut BufReader<File>) -> Result<PagerCommand, String> {
    let mut line = String::new();
    reader
        .read_line(&mut line)
        .map_err(|error| format!("failed to read pager input: {error}"))?;

    match line.trim() {
        "" => Ok(PagerCommand::Next),
        "b" | "B" => Ok(PagerCommand::Back),
        "q" | "Q" => Ok(PagerCommand::Quit),
        _ => Ok(PagerCommand::Next),
    }
}

fn page_height() -> usize {
    env::var("LINES")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .filter(|&lines| lines >= 4)
        .map(|lines| lines - 1)
        .unwrap_or(23)
}

enum PagerCommand {
    Next,
    Back,
    Quit,
}

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

    #[test]
    fn page_height_uses_fallback_when_missing() {
        unsafe {
            std::env::remove_var("LINES");
        }
        assert_eq!(page_height(), 23);
    }

    #[test]
    fn page_height_uses_env_value() {
        unsafe {
            std::env::set_var("LINES", "40");
        }
        assert_eq!(page_height(), 39);
        unsafe {
            std::env::remove_var("LINES");
        }
    }
}