clin-rs 0.8.13

Encrypted terminal note-taking app inspired by Obsidian
use std::io::{Read, Write};
use std::sync::mpsc;

use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use ratatui::style::{Color, Modifier, Style};

use once_cell::sync::Lazy;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use tui_term::vt100;

static GLOW_AVAILABLE: Lazy<AtomicBool> =
    Lazy::new(|| AtomicBool::new(which::which("glow").is_ok()));

pub fn glow_available() -> bool {
    GLOW_AVAILABLE.load(Ordering::Relaxed)
}

struct RenderResult {
    parser: vt100::Parser,
    content_rows: u16,
}

enum RendererState {
    Idle,
    Pending(mpsc::Receiver<Option<RenderResult>>),
    Ready,
}

pub struct MarkdownRenderer {
    state: RendererState,
    parser: Option<vt100::Parser>,
    content_rows: u16,
    cancel_token: Arc<AtomicBool>,
    pages: Vec<Vec<Vec<(char, Style)>>>,
    current_page: usize,
    total_pages: usize,
    content_empty: bool,
    theme_bg: Option<Color>,
}

impl Drop for MarkdownRenderer {
    fn drop(&mut self) {
        self.cancel_token.store(true, Ordering::Relaxed);
    }
}

impl MarkdownRenderer {
    pub fn new(cols: u16) -> Self {
        let rows = 200;
        Self {
            state: RendererState::Idle,
            parser: Some(vt100::Parser::new(rows, cols, 0)),
            content_rows: rows,
            cancel_token: Arc::new(AtomicBool::new(false)),
            pages: Vec::new(),
            current_page: 0,
            total_pages: 0,
            content_empty: true,
            theme_bg: None,
        }
    }

    pub fn render(&mut self, content: &str, cols: u16) {
        let estimated_rows = if content.is_empty() {
            1
        } else {
            (((content.lines().count() as u32 * 10) + 300).min(20000) as u16).clamp(300, 20000)
        };

        self.content_rows = estimated_rows;
        self.pages.clear();
        self.current_page = 0;
        self.total_pages = 0;
        self.content_empty = content.is_empty();

        if content.is_empty() {
            self.parser = Some(vt100::Parser::new(1, cols, 0));
            self.state = RendererState::Ready;
            return;
        }

        let content_owned = content.to_owned();
        let (tx, rx) = mpsc::channel();
        self.state = RendererState::Pending(rx);

        let cancel_token = Arc::clone(&self.cancel_token);
        std::thread::spawn(move || {
            let result = render_in_thread(&content_owned, cols, estimated_rows, cancel_token);
            let _ = tx.send(result);
        });
    }

    pub fn poll(&mut self) -> bool {
        let rx = match &self.state {
            RendererState::Pending(rx) => rx,
            _ => return false,
        };

        match rx.try_recv() {
            Ok(Some(result)) => {
                self.content_rows = result.content_rows;
                self.content_empty = result.parser.screen().contents().trim().is_empty();
                self.parser = Some(result.parser);
                self.state = RendererState::Ready;
                true
            }
            Ok(None) => {
                self.state = RendererState::Ready;
                true
            }
            Err(mpsc::TryRecvError::Empty) => false,
            Err(mpsc::TryRecvError::Disconnected) => {
                self.state = RendererState::Ready;
                true
            }
        }
    }

    pub fn is_pending(&self) -> bool {
        matches!(self.state, RendererState::Pending(_))
    }

    pub fn is_content_empty(&self) -> bool {
        self.content_empty
    }

    pub fn build_pages(&mut self, visible_rows: u16, theme_bg: Option<Color>) {
        self.theme_bg = theme_bg;
        let Some(parser) = &self.parser else {
            return;
        };
        let screen = parser.screen();
        let cols = screen.size().1;

        let mut all_rows: Vec<Vec<(char, Style)>> = Vec::new();
        let mut last_non_empty_row = 0usize;

        let mut effective_rows = 0u16;
        'scan: for r in (0..self.content_rows).rev() {
            for c in 0..cols {
                if let Some(screen_cell) = screen.cell(r, c)
                    && screen_cell.has_contents()
                    && !screen_cell.contents().trim().is_empty()
                {
                    effective_rows = r + 1;
                    break 'scan;
                }
            }
        }

        if effective_rows == 0 && self.content_rows > 0 {
            effective_rows = 1;
        }

        for row_idx in 0..effective_rows {
            let mut row_data: Vec<(char, Style)> = Vec::with_capacity(cols as usize);
            let mut has_content = false;
            for col in 0..cols {
                if let Some(screen_cell) = screen.cell(row_idx, col) {
                    let ch = if screen_cell.has_contents() {
                        let contents = screen_cell.contents();
                        has_content = has_content || !contents.trim().is_empty();
                        contents.chars().next().unwrap_or(' ')
                    } else {
                        ' '
                    };

                    let mut style = Style::reset();
                    if screen_cell.bold() {
                        style = style.add_modifier(Modifier::BOLD);
                    }
                    if screen_cell.italic() {
                        style = style.add_modifier(Modifier::ITALIC);
                    }
                    if screen_cell.underline() {
                        style = style.add_modifier(Modifier::UNDERLINED);
                    }
                    if screen_cell.inverse() {
                        style = style.add_modifier(Modifier::REVERSED);
                    }

                    let fg = convert_color(screen_cell.fgcolor());
                    let bg = match screen_cell.bgcolor() {
                        vt100::Color::Default => theme_bg.unwrap_or(Color::Reset),
                        other => convert_color(other),
                    };
                    style = style.fg(fg).bg(bg);

                    row_data.push((ch, style));
                } else {
                    row_data.push((' ', Style::default()));
                }
            }
            if has_content {
                last_non_empty_row = all_rows.len();
            }
            all_rows.push(row_data);
        }

        let trimmed_rows = &all_rows[..=last_non_empty_row.min(all_rows.len().saturating_sub(1))];

        let page_height = (visible_rows as usize).max(1);
        self.pages.clear();
        for chunk in trimmed_rows.chunks(page_height) {
            self.pages.push(chunk.to_vec());
        }

        self.total_pages = self.pages.len().max(1);
        self.current_page = 0;

        self.parser = None;
    }

    pub fn pages_built(&self) -> bool {
        !self.pages.is_empty() || self.content_empty
    }

    pub fn current_page_grid(&self) -> Option<&Vec<Vec<(char, Style)>>> {
        self.pages.get(self.current_page)
    }

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

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

    pub fn next_page(&mut self) {
        if self.total_pages > 0 && self.current_page < self.total_pages - 1 {
            self.current_page += 1;
        }
    }

    pub fn prev_page(&mut self) {
        self.current_page = self.current_page.saturating_sub(1);
    }
}

fn render_in_thread(
    content: &str,
    cols: u16,
    estimated_rows: u16,
    cancel_token: Arc<AtomicBool>,
) -> Option<RenderResult> {
    let mut parser = vt100::Parser::new(estimated_rows, cols, 0);

    if !glow_available() {
        process_fallback(&mut parser, content, estimated_rows);
        return Some(RenderResult {
            parser,
            content_rows: estimated_rows,
        });
    }

    if cancel_token.load(Ordering::Relaxed) {
        return None;
    }

    let mut temp_file = tempfile::Builder::new()
        .suffix(".md")
        .prefix("clin_md_")
        .tempfile()
        .ok()?;

    temp_file.write_all(content.as_bytes()).ok()?;
    temp_file.flush().ok()?;

    let temp_path = temp_file.path().to_owned();

    let pty_system = NativePtySystem::default();
    let pair = pty_system
        .openpty(PtySize {
            rows: estimated_rows,
            cols,
            pixel_width: 0,
            pixel_height: 0,
        })
        .ok()?;

    let mut cmd = CommandBuilder::new("glow");
    cmd.arg("-w");
    cmd.arg(cols.to_string());
    cmd.arg("-s");
    cmd.arg("dark");
    cmd.arg(&temp_path);
    cmd.env("TERM", "dumb");
    cmd.env("PAGER", "cat");
    cmd.env("GLOW_PAGER", "cat");

    let mut child = pair.slave.spawn_command(cmd).ok()?;
    drop(pair.slave);

    let mut reader = pair.master.try_clone_reader().ok()?;
    let _writer = pair.master.take_writer();

    let mut output = Vec::new();
    let mut buf = [0u8; 8192];

    loop {
        if cancel_token.load(Ordering::Relaxed) {
            let _ = child.kill();
            return None;
        }

        match reader.read(&mut buf) {
            Ok(0) => break,
            Ok(n) => output.extend_from_slice(&buf[..n]),
            Err(_) => break,
        }
    }

    let exit_ok = child.wait().map(|s| s.success()).unwrap_or(false);

    drop(_writer);
    drop(reader);
    drop(pair.master);
    drop(temp_file);

    if !output.is_empty() && exit_ok {
        parser.process(&output);
    } else {
        process_fallback(&mut parser, content, estimated_rows);
    }

    Some(RenderResult {
        parser,
        content_rows: estimated_rows,
    })
}

fn process_fallback(parser: &mut vt100::Parser, content: &str, estimated_rows: u16) {
    let mut fallback_output = Vec::new();
    for line in content.lines().take((estimated_rows - 3) as usize) {
        fallback_output.extend_from_slice(line.as_bytes());
        fallback_output.push(b'\n');
    }
    fallback_output
        .extend_from_slice(b"\n\x1b[38;5;242mInstall 'glow' for markdown rendering\x1b[0m\n");
    parser.process(&fallback_output);
}

fn convert_color(value: vt100::Color) -> Color {
    match value {
        vt100::Color::Default => Color::Reset,
        vt100::Color::Idx(0) => Color::Black,
        vt100::Color::Idx(1) => Color::Red,
        vt100::Color::Idx(2) => Color::Green,
        vt100::Color::Idx(3) => Color::Yellow,
        vt100::Color::Idx(4) => Color::Blue,
        vt100::Color::Idx(5) => Color::Magenta,
        vt100::Color::Idx(6) => Color::Cyan,
        vt100::Color::Idx(7) => Color::Gray,
        vt100::Color::Idx(8) => Color::DarkGray,
        vt100::Color::Idx(9) => Color::LightRed,
        vt100::Color::Idx(10) => Color::LightGreen,
        vt100::Color::Idx(11) => Color::LightYellow,
        vt100::Color::Idx(12) => Color::LightBlue,
        vt100::Color::Idx(13) => Color::LightMagenta,
        vt100::Color::Idx(14) => Color::LightCyan,
        vt100::Color::Idx(15) => Color::White,
        vt100::Color::Idx(i) => Color::Indexed(i),
        vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::AtomicBool;
    #[test]
    fn test_render_in_thread() {
        let cancel_token = Arc::new(AtomicBool::new(false));
        let folder_md =
            "# Vault (Root)\n\n## Folders\n- \u{f07b} Documents\n\n## Notes\n- \u{f15c} hello\n";
        let result = render_in_thread(folder_md, 80, 300, cancel_token);
        let res = result.unwrap();
        let contents = res.parser.screen().contents();
        eprintln!("Contents: {contents:?}");
        if glow_available() {
            assert!(!contents.contains("Install 'glow'"));
        } else {
            assert!(contents.contains("Install 'glow'"));
        }
    }
}