mlux 2.4.0

A rich Markdown viewer for modern terminals
Documentation
use std::fs;
use std::path::PathBuf;
use std::time::Instant;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use log::info;

use mlux::app_context::{AppContext, AppContextBuilder};
use mlux::config;
use mlux::input_source::{self, InputSource};

/// Default sidebar width in typst points for headless (non-terminal) rendering.
const DEFAULT_SIDEBAR_WIDTH_PT: f64 = 40.0;

fn long_version() -> &'static str {
    let base = env!("CARGO_PKG_VERSION");
    let hash = option_env!("MLUX_BUILD_GIT_HASH").unwrap_or("");
    let profile = option_env!("MLUX_BUILD_PROFILE").unwrap_or("unknown");
    let describe = option_env!("MLUX_BUILD_GIT_DESCRIBE").unwrap_or("");

    // git describe --tags --always output patterns:
    //   ""                     → no git (tarball, crates.io): use Cargo version
    //   starts with 'v'       → tag present: use as-is (e.g. "v0.4.1" or "v0.4.1-3-ge0e4555")
    //   otherwise             → no tags (shallow clone etc): "{base}-dev+{hash}"
    let version = if describe.is_empty() {
        base.to_string()
    } else if describe.starts_with('v') {
        describe.to_string()
    } else {
        format!("{base}-dev+{describe}")
    };

    if hash.is_empty() {
        format!("{version} ({profile})").leak()
    } else {
        format!("{version} (rev {hash}, {profile})").leak()
    }
}

#[derive(Parser)]
#[command(name = "mlux", version = long_version(), about = "Markdown viewer and renderer powered by Typst")]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    /// Input Markdown file (for view mode; use `-` for stdin)
    #[arg(global = true)]
    input: Option<PathBuf>,

    /// Theme name (loaded from themes/{name}.typ)
    #[arg(long, global = true)]
    theme: Option<String>,

    /// Enable automatic file watching (viewer reloads on file change)
    #[arg(short = 'w', long, global = true)]
    watch: bool,

    /// Disable Landlock filesystem sandbox (Linux only; fork is always used)
    #[arg(long, global = true)]
    no_sandbox: bool,

    /// Allow fetching remote images (http/https URLs) in Markdown
    #[arg(long, global = true)]
    allow_remote_images: bool,

    /// Log output file path (enables logging when specified)
    #[arg(long, global = true)]
    log: Option<PathBuf>,

    /// Enable debug-level logging (default is info level)
    #[arg(long, global = true)]
    debug: bool,

    /// Scroll behavior. `adaptive` is experimental — input-density-driven
    /// acceleration (see docs/2026-04-12-design-scroll-acceleration.md).
    #[arg(long, value_enum, global = true)]
    scroll: Option<ScrollModeArg>,

    /// Scroll animation algorithm (downstream interpolation — experimental).
    /// `exp-decay` is the v1 baseline; `exp-decay-adaptive` stretches
    /// the half-life on large jumps to keep gg/G trackable; `kinetic`
    /// is iOS-style momentum scroll with velocity friction-decay,
    /// stacking impulses across rapid keypresses.
    #[arg(long, value_enum, global = true)]
    scroll_animation: Option<ScrollAnimationArg>,

    /// Experimental preset bundling several scroll-related settings.
    /// Behavior may change between releases. Individual `--scroll` /
    /// `--scroll-animation` flags override values selected by the preset.
    #[arg(long, value_enum, global = true)]
    exp_preset: Option<ExpPresetArg>,
}

/// CLI-local mirror of [`mlux::config::ScrollMode`] — carries the clap
/// `ValueEnum` derive so the lib crate stays free of CLI-parsing concerns.
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
enum ScrollModeArg {
    Fixed,
    Adaptive,
}

impl From<ScrollModeArg> for mlux::config::ScrollMode {
    fn from(v: ScrollModeArg) -> Self {
        match v {
            ScrollModeArg::Fixed => Self::Fixed,
            ScrollModeArg::Adaptive => Self::Adaptive,
        }
    }
}

/// CLI-local mirror of [`mlux::config::ScrollAnimation`]. Same rationale
/// as [`ScrollModeArg`] — keeps the `ValueEnum` derive out of the lib crate.
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
enum ScrollAnimationArg {
    ExpDecay,
    ExpDecayAdaptive,
    Kinetic,
}

impl From<ScrollAnimationArg> for mlux::config::ScrollAnimation {
    fn from(v: ScrollAnimationArg) -> Self {
        match v {
            ScrollAnimationArg::ExpDecay => Self::ExpDecay,
            ScrollAnimationArg::ExpDecayAdaptive => Self::ExpDecayAdaptive,
            ScrollAnimationArg::Kinetic => Self::Kinetic,
        }
    }
}

/// CLI-local mirror of [`mlux::config::ExpPreset`].
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
enum ExpPresetArg {
    Adaptive,
}

impl From<ExpPresetArg> for mlux::config::ExpPreset {
    fn from(v: ExpPresetArg) -> Self {
        match v {
            ExpPresetArg::Adaptive => Self::Adaptive,
        }
    }
}

#[derive(Subcommand)]
enum Command {
    /// Render Markdown to PNG
    Render {
        /// Input Markdown file (use `-` for stdin)
        input: PathBuf,

        /// Output PNG file
        #[arg(short, long, default_value = "output.png")]
        output: PathBuf,

        /// Page width in pt
        #[arg(long)]
        width: Option<f64>,

        /// Output resolution in PPI
        #[arg(long)]
        ppi: Option<f32>,

        /// Tile height in pt
        #[arg(long)]
        tile_height: Option<f64>,

        /// Typography zoom factor (1.0 = default; e.g. 1.5 for 150%)
        #[arg(long)]
        scale: Option<f64>,

        /// Dump frame tree to stderr
        #[arg(long)]
        dump: bool,
    },
}

fn main() {
    let cli = Cli::parse();

    let log_file: Option<Box<dyn std::io::Write + Send>> = if let Some(log_path) = &cli.log {
        let file = std::fs::File::create(log_path).expect("failed to open log file");
        Some(Box::new(file))
    } else {
        None
    };
    let log_buffer = mlux::log::init(cli.debug, log_file);

    // Extract render-subcommand CLI overrides (width/ppi/tile_height/scale)
    let (render_width, render_ppi, render_tile_height, render_scale) = match &cli.command {
        Some(Command::Render {
            width,
            ppi,
            tile_height,
            scale,
            ..
        }) => (*width, *ppi, *tile_height, *scale),
        None => (None, None, None, None),
    };

    // Build CliOverrides
    let cli_overrides = config::CliOverrides {
        theme: cli.theme.clone(),
        width: render_width,
        ppi: render_ppi,
        tile_height: render_tile_height,
        scale: render_scale,
        allow_remote_images: cli.allow_remote_images,
        scroll_mode: cli.scroll.map(Into::into),
        scroll_animation: cli.scroll_animation.map(Into::into),
        exp_preset: cli.exp_preset.map(Into::into),
    };

    let mut config = config::Config::default();
    config.apply_cli(&cli_overrides);

    // Theme detection: only when theme is "auto" and stdout is a TTY
    let detected_light = if config.theme == "auto" {
        use std::io::IsTerminal;
        if std::io::stdout().is_terminal() {
            let _raw = crossterm::terminal::enable_raw_mode();
            let result = mlux::viewer::detect_terminal_theme(std::time::Duration::from_millis(100))
                == mlux::viewer::TerminalTheme::Light;
            let _ = crossterm::terminal::disable_raw_mode();
            result
        } else {
            false
        }
    } else {
        false
    };

    // Build InputSource and read markdown
    let render_input_path = cli
        .command
        .as_ref()
        .map(|Command::Render { input, .. }| input.clone());
    let mut input_source = build_input_source(cli.input.or(render_input_path));

    let markdown = match input_source.read_all() {
        Ok(md) => md,
        Err(e) => {
            eprintln!("Error: {e:#}");
            std::process::exit(1);
        }
    };

    // Build AppContext: shared initialization for both modes.
    // Theme resolution is deferred to the build pipeline (prescan → CJK → theme).
    let app = match AppContextBuilder::new(config, cli_overrides)
        .load_fonts()
        .set_detected_light(detected_light)
        .build()
    {
        Ok(app) => app,
        Err(e) => {
            eprintln!("Error: {e:#}");
            std::process::exit(1);
        }
    };

    let base_dir = match &input_source {
        InputSource::File(path) => path.parent().map(|d| d.to_path_buf()),
        InputSource::Stdin(_) => None,
    };
    let file_path = match &input_source {
        InputSource::File(path) => Some(path.clone()),
        InputSource::Stdin(_) => None,
    };

    let result = match cli.command {
        Some(Command::Render { output, dump, .. }) => cmd_render(
            app,
            &input_source,
            markdown,
            base_dir,
            file_path,
            output,
            dump,
            cli.no_sandbox,
            &log_buffer,
        ),
        None => mlux::viewer::run(
            app,
            input_source,
            markdown,
            cli.watch,
            cli.no_sandbox,
            log_buffer,
        ),
    };

    if let Err(e) = result {
        let msg = format!("{e:#}");
        if msg.contains("[BUG]") {
            eprintln!("\x1b[1;31m{msg}\x1b[0m");
        } else {
            eprintln!("Error: {msg}");
        }
        std::process::exit(1);
    }
}

fn build_input_source(input: Option<PathBuf>) -> InputSource {
    if input_source::is_stdin_input(input.as_deref()) {
        InputSource::Stdin(input_source::StdinReader::new())
    } else {
        match input {
            Some(p) => match p.canonicalize() {
                Ok(canonical) => InputSource::File(canonical),
                Err(e) => {
                    eprintln!("Error: failed to resolve {}: {e}", p.display());
                    std::process::exit(1);
                }
            },
            None => {
                eprintln!("Error: input file required (or pipe via stdin)");
                std::process::exit(1);
            }
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn cmd_render(
    app: AppContext,
    input: &InputSource,
    markdown: String,
    base_dir: Option<PathBuf>,
    file_path: Option<PathBuf>,
    output: PathBuf,
    dump: bool,
    no_sandbox: bool,
    log_buffer: &mlux::log::LogBuffer,
) -> Result<()> {
    let pipeline_start = Instant::now();

    if markdown.trim().is_empty() {
        anyhow::bail!("input file is empty or contains only whitespace");
    }

    let params = app.build_params(
        markdown.clone(),
        base_dir,
        file_path,
        app.config.width,
        DEFAULT_SIDEBAR_WIDTH_PT,
        app.config.viewer.tile_height,
        false,
    );

    if dump {
        let mut child = mlux::renderer::build_dump(&params, no_sandbox, log_buffer)?;
        let code = child.wait()?;
        if code != 0 {
            anyhow::bail!("dump failed (child exited with code {code})");
        }
        return Ok(());
    }

    let output_parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    fs::create_dir_all(output_parent).ok();

    let stem = output
        .file_stem()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();
    let ext = output
        .extension()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();

    let (meta, mut renderer, mut _child) =
        mlux::renderer::build_renderer_blocking(&params, no_sandbox, log_buffer)?;

    let mut files = Vec::new();
    for i in 0..meta.tile_count {
        let pngs = renderer.render_tile_pair(i)?;
        let filename = format!("{}-{:03}.{}", stem, i, ext);
        let path = output_parent.join(&filename);
        fs::write(&path, &pngs.content)
            .with_context(|| format!("failed to write {}", path.display()))?;
        files.push((filename, pngs.content.len()));
    }
    renderer.shutdown();

    info!(
        "cmd_render: total pipeline completed in {:.1}ms",
        pipeline_start.elapsed().as_secs_f64() * 1000.0
    );

    let input_name = input.display_name();
    eprintln!("rendered {} -> {} tile(s):", input_name, meta.tile_count);
    for (filename, size) in &files {
        eprintln!("  {} ({} bytes)", filename, size);
    }

    Ok(())
}