leaf-markdown-viewer 1.26.2

Terminal Markdown previewer with a GUI-like experience
use super::App;
use crate::markdown::{
    hash_file_contents, hash_str, parse_markdown_with_width, read_file_state, ParseResult,
};
use std::{
    path::PathBuf,
    time::{Duration, Instant, SystemTime},
};
use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};

use crate::theme::{app_theme, current_syntect_theme};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FileState {
    pub(crate) modified: SystemTime,
    pub(crate) len: u64,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FileChange {
    Metadata(FileState),
    Content(FileState),
}

impl App {
    pub(crate) fn replace_content(&mut self, parsed: ParseResult) {
        use crate::markdown::build_searchable_lines;
        use crate::render::toc_header_line;

        let ParseResult {
            lines,
            toc,
            link_spans,
            line_number_map,
            source_line_map,
            code_blocks,
        } = parsed;

        self.plain_lines = build_searchable_lines(&lines)
            .into_iter()
            .map(|line| line.to_lowercase())
            .collect();
        self.lines = lines;
        self.toc = toc;
        self.highlighted_line_cache = None;
        self.toc_header_line = toc_header_line();
        self.link_spans_by_line = super::links::link_spans_to_map(link_spans);
        self.hovered_link = None;
        self.set_code_blocks(code_blocks);
        self.code_select = None;
        self.set_line_maps(line_number_map, source_line_map);
        self.refresh_static_caches();
    }

    pub(crate) fn reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool {
        self.reset_numkey_state();
        self.clear_toc_scroll_state();
        let path = match &self.filepath {
            Some(p) => p,
            None => return false,
        };
        let src = match std::fs::read_to_string(path) {
            Ok(s) => s,
            Err(_) => return false,
        };
        let file_state = read_file_state(path);
        let content_hash = hash_str(&src);
        self.source = if self.file_mode {
            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            Self::fence_wrap(&src, ext)
        } else {
            src
        };

        self.reparse_source(ss, themes);
        self.last_file_state = file_state;
        self.last_content_hash = content_hash;
        self.last_hash_check = Some(Instant::now());
        if self.watch_flash.is_none() {
            self.reload_flash = Some(Instant::now());
        }
        true
    }

    pub(crate) fn load_path(&mut self, path: PathBuf, ss: &SyntaxSet, themes: &ThemeSet) -> bool {
        let src = match std::fs::read_to_string(&path) {
            Ok(src) => src,
            Err(_) => return false,
        };
        let filename = path
            .file_name()
            .map(|name| name.to_string_lossy().to_string())
            .unwrap_or_else(|| path.display().to_string());
        let file_state = read_file_state(&path);
        let content_hash = hash_str(&src);
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        let (src, is_code_file) = Self::wrap_as_code_block(src, ext, ss);
        self.file_mode = is_code_file;
        let theme = current_syntect_theme(themes);
        let at = app_theme();
        let parsed = parse_markdown_with_width(
            &src,
            ss,
            theme,
            self.render_width,
            &at.markdown,
            self.file_mode,
            self.code_line_numbers,
        );

        let first_load = self.filepath.is_none();
        self.filename = filename;
        self.source = src;
        self.filepath = Some(path);
        if first_load && self.watch_from_config {
            self.watch = true;
            self.watch_error = false;
        }
        self.last_file_state = file_state;
        self.last_content_hash = content_hash;
        self.last_hash_check = Some(Instant::now());
        self.reload_flash = None;
        self.scroll = 0;
        self.help_open = false;
        self.file_picker.open = false;
        self.theme_picker.open = false;
        self.search.mode = false;
        self.reset_search_state();
        self.clear_active_goto_line();
        self.invalidate_theme_preview_cache();
        self.store_current_theme_preview_from(&parsed.lines, &parsed.toc);
        self.replace_content(parsed);
        self.clear_toc_scroll_state();
        true
    }

    pub(crate) fn reparse_source(&mut self, ss: &SyntaxSet, themes: &ThemeSet) {
        let theme = current_syntect_theme(themes);
        let at = app_theme();
        let old_total = self.total();
        let parsed = parse_markdown_with_width(
            &self.source,
            ss,
            theme,
            self.render_width,
            &at.markdown,
            self.file_mode,
            self.code_line_numbers,
        );
        let new_total = parsed.lines.len();

        if old_total > 0 {
            self.scroll = ((self.scroll as f64 / old_total as f64) * new_total as f64) as usize;
            let vh = self.content_area.height as usize;
            self.scroll = self.scroll.min(new_total.saturating_sub(vh));
        }

        self.invalidate_theme_preview_cache();
        self.store_current_theme_preview_from(&parsed.lines, &parsed.toc);
        self.replace_content(parsed);
        self.goto_line.target = None;
        self.goto_line.error = false;
        if !self.search.query.is_empty() && !self.search.mode {
            self.run_search();
        }
    }

    pub(crate) fn check_modified(&mut self) -> Option<FileChange> {
        const HASH_FALLBACK_INTERVAL: Duration = Duration::from_secs(2);

        let path = self.filepath.as_ref()?;
        let state = read_file_state(path)?;
        match self.last_file_state {
            Some(prev) if state.modified != prev.modified || state.len != prev.len => {
                Some(FileChange::Metadata(state))
            }
            Some(_) => {
                let should_hash = self
                    .last_hash_check
                    .map(|checked_at| checked_at.elapsed() >= HASH_FALLBACK_INTERVAL)
                    .unwrap_or(true);
                if !should_hash {
                    return None;
                }
                self.last_hash_check = Some(Instant::now());
                let current_hash = hash_file_contents(path).ok()?;
                (current_hash != self.last_content_hash).then_some(FileChange::Content(state))
            }
            None => Some(FileChange::Metadata(state)),
        }
    }

    pub(crate) fn request_reload(&mut self, ss: &SyntaxSet, themes: &ThemeSet) -> bool {
        self.last_file_state = None;
        self.reload(ss, themes)
    }

    pub(crate) fn set_last_content_hash(&mut self, last_content_hash: u64) {
        self.last_content_hash = last_content_hash;
    }

    pub(crate) fn set_last_file_state(&mut self, state: FileState) {
        self.last_file_state = Some(state);
    }

    pub(crate) fn sync_render_width(
        &mut self,
        render_width: usize,
        ss: &SyntaxSet,
        themes: &ThemeSet,
    ) -> bool {
        let next_width = render_width.max(20);
        if self.render_width == next_width {
            return false;
        }
        self.render_width = next_width;
        self.reparse_source(ss, themes);
        true
    }
}