mod action;
mod app;
mod cast;
mod config;
mod event;
mod fs;
mod markdown;
mod mermaid;
mod state;
mod text_layout;
mod theme;
mod ui;
use anyhow::{Context, Result};
use app::App;
use clap::Parser;
use crossterm::{
cursor::Show,
event::{
DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::prelude::*;
use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
let _ = execute!(
std::io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
Show,
);
}
}
#[derive(Parser, Debug)]
#[command(name = "markdown-reader", about = "A TUI markdown file viewer")]
struct Cli {
#[arg(default_value = ".")]
path: PathBuf,
}
fn drain_stdin_to_temp() -> Result<tempfile::NamedTempFile> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("failed to read piped markdown from stdin")?;
let mut temp = tempfile::Builder::new()
.prefix("stdin-")
.suffix(".md")
.tempfile()
.context("failed to create temp file for stdin content")?;
temp.write_all(buf.as_bytes())
.context("failed to write stdin content to temp file")?;
temp.flush().context("failed to flush temp file")?;
Ok(temp)
}
#[cfg(unix)]
fn redirect_stdin_to_tty() -> Result<()> {
use std::os::unix::io::AsRawFd;
let tty =
std::fs::File::open("/dev/tty").context("failed to open /dev/tty for keyboard input")?;
unsafe extern "C" {
fn dup2(oldfd: std::ffi::c_int, newfd: std::ffi::c_int) -> std::ffi::c_int;
}
let result = unsafe { dup2(tty.as_raw_fd(), 0) };
if result < 0 {
return Err(anyhow::anyhow!(
"dup2(/dev/tty, stdin) failed: {}",
std::io::Error::last_os_error()
));
}
drop(tty);
Ok(())
}
#[cfg(not(unix))]
fn redirect_stdin_to_tty() -> Result<()> {
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let stdin_temp = if std::io::stdin().is_terminal() {
None
} else {
let temp = drain_stdin_to_temp()?;
redirect_stdin_to_tty()?;
Some(temp)
};
let (root, initial_file) = if let Some(temp) = stdin_temp.as_ref() {
let path = temp.path().canonicalize()?;
let parent = path
.parent()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
(parent, Some(path))
} else {
let canonical = cli.path.canonicalize()?;
if canonical.is_file() {
let parent = canonical
.parent()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
(parent, Some(canonical))
} else {
(canonical, None)
}
};
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
let _guard = TerminalGuard;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = App::new(root, initial_file).run(&mut terminal).await;
drop(stdin_temp);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drain_stdin_writes_md_temp_file_with_content() {
let mut temp = tempfile::Builder::new()
.prefix("stdin-")
.suffix(".md")
.tempfile()
.unwrap();
let content = "# hello from stdin\n\nThis is a test.\n";
temp.write_all(content.as_bytes()).unwrap();
temp.flush().unwrap();
let path = temp.path();
assert!(
path.extension().is_some_and(|e| e == "md"),
"temp file must have .md suffix: {path:?}"
);
let read_back = std::fs::read_to_string(path).unwrap();
assert_eq!(read_back, content);
}
}