leaf-markdown-viewer 1.26.2

Terminal Markdown previewer with a GUI-like experience
mod keyboard;
mod mouse;

use crate::{
    app::{App, FileChange, FLASH_DURATION_MS},
    render::{ui, CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH},
};
use anyhow::Result;
use crossterm::event::{self, poll, Event, KeyEventKind};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::{
    fs::OpenOptions,
    io,
    io::Write,
    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};

use keyboard::{handle_key_event, HandleResult};

const EDITOR_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
const WATCH_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
const CONFIG_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
const LINK_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
const CODE_BLOCK_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
const PATH_FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
const DOUBLE_CLICK_THRESHOLD: Duration = Duration::from_millis(400);
const MOUSE_SCROLL_STEP: usize = 3;

pub(crate) fn should_handle_key(kind: KeyEventKind) -> bool {
    !matches!(kind, KeyEventKind::Release)
}

pub(crate) fn debug_log(enabled: bool, message: &str) {
    if !enabled {
        return;
    }
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or(0);
    if let Ok(mut file) = OpenOptions::new()
        .create(true)
        .append(true)
        .open("leaf-debug.log")
    {
        let _ = writeln!(file, "[{timestamp}] {message}");
    }
}

pub(crate) fn prepare_initial_picker_state(
    area_width: usize,
    app: &mut App,
    ss: &SyntaxSet,
    themes: &ThemeSet,
) -> Result<()> {
    debug_log(
        app.debug_input_enabled(),
        &format!("prepare_initial_picker_state start area_width={area_width}"),
    );
    sync_render_width_for_app(area_width, app, ss, themes);
    if app.has_pending_picker() && !app.is_picker_loading() {
        let _ = app.start_pending_picker_loading();
    }
    debug_log(
        app.debug_input_enabled(),
        &format!(
            "prepare_initial_picker_state end picker_loading={} pending_picker={}",
            app.is_picker_loading(),
            app.has_pending_picker()
        ),
    );
    Ok(())
}

pub(crate) fn run(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    ss: &SyntaxSet,
    themes: &ThemeSet,
    initial_draw_done: bool,
) -> Result<()> {
    const WATCH_INTERVAL: Duration = Duration::from_millis(250);
    const FLASH_DURATION: Duration = Duration::from_millis(FLASH_DURATION_MS);
    const RESIZE_DEBOUNCE: Duration = Duration::from_millis(120);
    const PICKER_LOAD_POLL_INTERVAL: Duration = Duration::from_millis(50);
    let mut needs_redraw = !initial_draw_done;
    let mut pending_resize: Option<Instant> = None;
    let mut last_title_filename: Option<String> = app.title_filename().map(str::to_string);
    sync_render_width(terminal, app, ss, themes)?;

    loop {
        if app.has_pending_picker() && !app.is_picker_loading() {
            let _ = app.start_pending_picker_loading();
            needs_redraw = true;
        }
        if app.poll_picker_loading() {
            needs_redraw = true;
        }

        let current_title_filename = app.title_filename();
        if current_title_filename != last_title_filename.as_deref() {
            crate::terminal::set_tab_title(
                current_title_filename,
                app.tab_title_max_filename_len(),
            );
            last_title_filename = current_title_filename.map(str::to_string);
        }

        if needs_redraw {
            terminal.draw(|f| ui(f, app))?;
            needs_redraw = false;
        }

        let flash_timeout = app
            .reload_flash_started()
            .and_then(|started| FLASH_DURATION.checked_sub(started.elapsed()));
        let editor_flash_timeout = app
            .editor_flash()
            .and_then(|(_, started)| EDITOR_FLASH_DURATION.checked_sub(started.elapsed()));
        let watch_flash_timeout = app
            .watch_flash()
            .and_then(|(_, started)| WATCH_FLASH_DURATION.checked_sub(started.elapsed()));
        let config_flash_timeout = app
            .config_flash()
            .and_then(|(_, started)| CONFIG_FLASH_DURATION.checked_sub(started.elapsed()));
        let link_flash_timeout = app
            .link_flash()
            .and_then(|(_, started)| LINK_FLASH_DURATION.checked_sub(started.elapsed()));
        let code_block_flash_timeout = app
            .code_block_flash()
            .and_then(|(_, started)| CODE_BLOCK_FLASH_DURATION.checked_sub(started.elapsed()));
        let resize_timeout =
            pending_resize.and_then(|started| RESIZE_DEBOUNCE.checked_sub(started.elapsed()));
        let poll_timeout = [
            if app.is_watch_enabled() {
                Some(WATCH_INTERVAL)
            } else {
                None
            },
            if app.is_picker_loading() {
                Some(PICKER_LOAD_POLL_INTERVAL)
            } else {
                None
            },
            flash_timeout,
            editor_flash_timeout,
            watch_flash_timeout,
            config_flash_timeout,
            link_flash_timeout,
            code_block_flash_timeout,
            app.path_flash()
                .and_then(|(_, started)| PATH_FLASH_DURATION.checked_sub(started.elapsed())),
            resize_timeout,
        ]
        .into_iter()
        .flatten()
        .min()
        .unwrap_or(Duration::MAX);

        let event_available = if poll_timeout == Duration::MAX {
            true
        } else {
            poll(poll_timeout)?
        };

        if event_available {
            match event::read()? {
                Event::Key(key) => {
                    debug_log(
                        app.debug_input_enabled(),
                        &format!(
                            "key_event kind={:?} code={:?} modifiers={:?} search_mode={} query={:?} draft={:?} matches={} idx={}",
                            key.kind,
                            key.code,
                            key.modifiers,
                            app.is_search_mode(),
                            app.search_query(),
                            app.search_draft(),
                            app.search_match_count(),
                            app.search_index()
                        ),
                    );
                    if !should_handle_key(key.kind) {
                        continue;
                    }
                    match handle_key_event(terminal, app, key, ss, themes)? {
                        HandleResult::Break => break,
                        HandleResult::Continue { redraw } => {
                            if redraw {
                                needs_redraw = true;
                            }
                        }
                    }
                    if sync_render_width(terminal, app, ss, themes)? {
                        needs_redraw = true;
                    }
                }
                Event::Mouse(mouse) => {
                    if app.debug_input_enabled() {
                        debug_log(
                            true,
                            &format!(
                                "mouse_event kind={:?} col={} row={} modifiers={:?}",
                                mouse.kind, mouse.column, mouse.row, mouse.modifiers
                            ),
                        );
                    }
                    if mouse::handle_mouse_event(app, mouse) {
                        needs_redraw = true;
                    }
                }
                Event::Resize(_, _) => {
                    if app.exit_code_select_mode() {
                        needs_redraw = true;
                    }
                    pending_resize = Some(Instant::now());
                }
                _ => {}
            }
        }

        if pending_resize
            .map(|started| started.elapsed() >= RESIZE_DEBOUNCE)
            .unwrap_or(false)
        {
            pending_resize = None;
            sync_render_width(terminal, app, ss, themes)?;
            needs_redraw = true;
        }

        if app.is_watch_enabled() {
            let file_ok = app.filepath().map(|p| p.exists()).unwrap_or(false);
            if !file_ok && !app.is_watch_error() {
                app.set_watch_error(true);
                needs_redraw = true;
            } else if file_ok && app.is_watch_error() {
                app.set_watch_error(false);
                needs_redraw = true;
            }
            if file_ok {
                if let Some(change) = app.check_modified() {
                    std::thread::sleep(Duration::from_millis(50));
                    if app.reload(ss, themes) {
                        app.set_last_file_state(match change {
                            FileChange::Metadata(state) | FileChange::Content(state) => state,
                        });
                        needs_redraw = true;
                    }
                }
            }
            if let Some(t) = app.reload_flash_started() {
                if t.elapsed() >= FLASH_DURATION {
                    app.clear_reload_flash();
                    needs_redraw = true;
                }
            }
        }

        if let Some((_, started)) = app.editor_flash() {
            if started.elapsed() >= EDITOR_FLASH_DURATION {
                app.clear_editor_flash();
                needs_redraw = true;
            }
        }

        if let Some((_, started)) = app.watch_flash() {
            if started.elapsed() >= WATCH_FLASH_DURATION {
                app.clear_watch_flash();
                needs_redraw = true;
            }
        }

        if let Some((_, started)) = app.config_flash() {
            if started.elapsed() >= CONFIG_FLASH_DURATION {
                app.clear_config_flash();
                needs_redraw = true;
            }
        }

        if let Some((_, started)) = app.link_flash() {
            if started.elapsed() >= LINK_FLASH_DURATION {
                app.clear_link_flash();
                needs_redraw = true;
            }
        }

        if let Some((_, started)) = app.path_flash() {
            if started.elapsed() >= PATH_FLASH_DURATION {
                app.clear_path_flash();
                needs_redraw = true;
            }
        }
    }
    Ok(())
}

fn sync_render_width(
    terminal: &Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    ss: &SyntaxSet,
    themes: &ThemeSet,
) -> Result<bool> {
    let area = terminal.size()?;
    Ok(sync_render_width_for_app(
        area.width as usize,
        app,
        ss,
        themes,
    ))
}

fn sync_render_width_for_app(
    area_width: usize,
    app: &mut App,
    ss: &SyntaxSet,
    themes: &ThemeSet,
) -> bool {
    let content_width = if app.is_toc_visible() && app.has_toc() {
        area_width.saturating_sub(30)
    } else {
        area_width
    };
    let gutter = app.line_number_gutter_width();
    let effective_width = content_width
        .saturating_sub(CONTENT_HORIZONTAL_PADDING as usize * 2)
        .saturating_sub(SCROLLBAR_WIDTH as usize)
        .saturating_sub(gutter);
    let capped_width = effective_width.min(app.max_width().unwrap_or(usize::MAX));
    app.sync_render_width(capped_width, ss, themes)
}