mod big_text;
mod config;
mod cursor;
mod debug;
mod document;
mod error;
mod keybindings;
mod model;
mod renderer;
mod setup;
mod sources;
mod view;
mod watch;
mod worker;
#[cfg(not(windows))]
use std::os::fd::IntoRawFd as _;
use std::{
fmt::Display,
io::{self, Read as _},
sync::{
Arc, OnceLock, RwLock,
mpsc::{self},
},
};
use clap::{ArgMatches, arg, command, value_parser};
use ratatui::{
Terminal,
crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
tty::IsTty as _,
},
layout::Size,
prelude::CrosstermBackend,
};
use mdfrier::MarkdownLink;
use ratatui_image::{picker::ProtocolType, protocol::Protocol, sliced::SlicedProtocol};
use setup::{SetupResult, setup_graphics};
use crate::{
config::Config,
document::{Section, SectionID},
error::Error,
model::{DocumentId, Model},
renderer::run_loop,
sources::{DocumentSource, SharedDocumentSource, open_source},
watch::watch,
worker::{ImageCache, worker_thread},
};
pub const OK_END: &str = " ok.";
pub static VERSION: OnceLock<String> = OnceLock::new();
fn main() -> io::Result<()> {
let mut cmd = command!() .arg(
arg!([SOURCE] "The markdown source.\nCan be a file path, a URL, a github repo in \"github:[owner]/[repo]\" format, or '-' or omit, for stdin.")
)
.arg(arg!(-d --"deep-fry" "Extra deep fried images.").value_parser(value_parser!(bool)))
.arg(arg!(-w --"watch" "Watch markdown file, reload on changes.").value_parser(value_parser!(bool)))
.arg(arg!(-s --"setup" "Force font setup (again).").value_parser(value_parser!(bool)))
.arg(
arg!(--"print-config" "Write out a mostly complete config file example to stdout.")
.value_parser(value_parser!(bool)),
)
.arg(
arg!(--"no-cap-checks" "Do not query the terminal stdin for capabilities.")
.value_parser(value_parser!(bool)),
)
.arg(arg!(--"debug-override-protocol-type" <PROTOCOL> "Force graphics protocol to a specific type."))
.arg(
arg!(--log [FILE] "Log to a file with RUST_LOG, or stderr if omitted with RUST_LOG=debug.\nStderr should always be redirected, e.g. 2>/dev/pts/<tty> to pipe into another terminal.")
.num_args(0..=1)
.default_missing_value("")
.value_parser(value_parser!(String)),
)
.arg(arg!(--"animate" "Animate scrolling on startup (for demo recordings).").hide(true).value_parser(value_parser!(bool)))
;
let matches = cmd.get_matches_mut();
if let Some(version) = cmd.get_version() {
#[expect(unused_must_use)]
VERSION.set(version.to_owned());
}
match main_with_args(&matches) {
Err(Error::Usage(msg)) => {
if let Some(msg) = msg {
println!("Usage error: {msg}");
println!();
}
cmd.write_help(&mut io::stdout())?;
}
Err(Error::UserAbort(msg)) => {
println!("Abort: {msg}");
}
Err(err) => eprintln!("{err}"),
_ => {}
}
Ok(())
}
#[expect(clippy::too_many_lines)]
fn main_with_args(matches: &ArgMatches) -> Result<(), Error> {
let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
.panic_section(format!(
"This is a bug. Consider reporting it at {}",
env!("CARGO_PKG_REPOSITORY")
))
.display_location_section(true)
.display_env_section(true)
.into_hooks();
eyre_hook.install()?;
std::panic::set_hook(Box::new(move |panic_info| {
if let Err(err) = crossterm::terminal::disable_raw_mode() {
eprintln!("Unable to disable raw mode: {:?}", err);
}
let msg = format!("{}", panic_hook.panic_report(panic_info));
log::error!("Panic: {}", msg);
eprintln!("{msg}");
#[expect(clippy::exit)]
std::process::exit(libc::EXIT_FAILURE);
}));
if *matches.get_one("print-config").unwrap_or(&false) {
config::print_default()?;
return Ok(());
}
let log = matches.get_one::<String>("log");
debug::init_logger(debug::LogTarget::from(log))?;
let source: Option<String> = matches.get_one::<String>("SOURCE").cloned();
let mut user_config = config::load_or_ask()?;
let config = Config::from(user_config.clone());
let (text, document_source) = match source {
Some(source) if source == "-" => {
let mut text = String::new();
print!("Reading stdin...");
io::stdin().read_to_string(&mut text)?;
println!("{OK_END}");
(text, DocumentSource::Stdin { text: None })
}
None => {
if io::stdin().is_tty() {
return Err(Error::Usage(Some(
"no source nor '-', and stdin is a tty (not a pipe)",
)));
}
let mut text = String::new();
print!("Reading stdin...");
io::stdin().read_to_string(&mut text)?;
println!("{OK_END}");
(text, DocumentSource::Stdin { text: None })
}
Some(source) => open_source(&source, config.url_transform_command.clone())?,
};
if text.is_empty() {
return Err(Error::Usage(Some("no input or empty")));
}
#[cfg(not(windows))]
if !io::stdin().is_tty() {
print!("Setting stdin to /dev/tty...");
unsafe {
let tty = std::fs::File::open("/dev/tty")?;
let tty_fd = tty.into_raw_fd();
libc::dup2(tty_fd, libc::STDIN_FILENO);
libc::close(tty_fd);
}
println!("{OK_END}");
}
let force_setup = *matches.get_one("setup").unwrap_or(&false);
let no_cap_checks = *matches.get_one("no-cap-checks").unwrap_or(&false);
let debug_override_protocol_type = config.debug_override_protocol_type.or(matches
.get_one::<String>("debug-override-protocol-type")
.map(|s| match s.as_str() {
"Sixel" => ProtocolType::Sixel,
"Iterm2" => ProtocolType::Iterm2,
"Kitty" => ProtocolType::Kitty,
_ => ProtocolType::Halfblocks,
}));
let (picker, renderer, has_text_size_protocol) = {
let setup_result = setup_graphics(
&mut user_config,
force_setup,
no_cap_checks,
debug_override_protocol_type,
);
match setup_result {
Ok(result) => match result {
SetupResult::Aborted => return Err(Error::UserAbort("cancelled setup")),
SetupResult::TextSizing(picker) => (picker, None, true),
SetupResult::AsciiArt(picker) => (picker, None, false),
SetupResult::Complete(picker, renderer) => (picker, Some(renderer), false),
},
Err(err) => return Err(err),
}
};
let deep_fry = *matches.get_one("deep-fry").unwrap_or(&false);
let watchmode_path = if *matches.get_one("watch").unwrap_or(&false)
&& let DocumentSource::File { path, .. } = &document_source
{
Some(path.clone())
} else {
None
};
let document_source = SharedDocumentSource(Arc::new(RwLock::new(document_source)));
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let (event_tx, event_rx) = mpsc::channel::<Event>();
let watch_event_tx = event_tx.clone();
#[cfg(not(windows))]
if *matches.get_one("animate").unwrap_or(&false) {
log::warn!("--animate");
debug::animate_recording(event_tx.clone());
}
let config_max_image_height = config.max_image_height;
let mut worker_theme = config.theme.clone();
worker_theme.has_text_size_protocol = Some(has_text_size_protocol);
let cmd_thread = worker_thread(
document_source.clone(),
picker,
renderer,
worker_theme,
deep_fry,
cmd_rx,
event_tx,
config_max_image_height,
);
crossterm::terminal::enable_raw_mode()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let enable_mouse_capture = config.enable_mouse_capture;
if enable_mouse_capture {
crossterm::execute!(io::stderr(), EnableMouseCapture)?;
}
let watch_debounce_milliseconds = config.watch_debounce_milliseconds;
terminal.clear()?;
let model = Model::new(document_source, cmd_tx, event_rx, terminal.size()?, config);
model.open(text)?;
let debouncer = if let Some(path) = watchmode_path {
log::info!("watching file");
Some(watch(&path, watch_event_tx, watch_debounce_milliseconds)?)
} else {
drop(watch_event_tx);
None
};
if let Err(err) = run_loop(terminal, model) {
eprintln!("Runtime error: {err}");
};
drop(debouncer);
if enable_mouse_capture {
crossterm::execute!(io::stderr(), DisableMouseCapture)?;
}
crossterm::terminal::disable_raw_mode()?;
if let Err(e) = cmd_thread.join() {
eprintln!("Thread error: {e:?}");
}
Ok(())
}
enum Cmd {
Parse(DocumentId, u16, String, Option<ImageCache>),
OpenUrl(String),
}
impl std::fmt::Debug for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(self, f)
}
}
impl Display for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Cmd::Parse(reload_id, width, _, cache) => {
write!(
f,
"Cmd::Parse({reload_id:?}, {width}, <text>, cache={cache:?})",
)
}
Cmd::OpenUrl(url) => write!(f, "Cmd::Open({url})"),
}
}
}
pub enum Event {
NewDocument(DocumentId),
ParseDone(DocumentId, Option<SectionID>, String), Parsed(DocumentId, Section),
ImageLoaded(
DocumentId,
SectionID,
MarkdownLink,
(SlicedProtocol, Size, Size),
),
ImageFailed(DocumentId, SectionID, String, String),
HeaderLoaded(DocumentId, SectionID, Vec<(String, u8, Protocol)>),
FileChanged,
Scroll(i16),
NewSourceContent(String),
ReferenceDefinition {
id: String,
url: String,
},
}
impl Display for Event {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Event::NewDocument(document_id) => write!(f, "Event::NewDocument({document_id})"),
Event::ParseDone(document_id, last_section_id, _text) => {
write!(f, "Event::ParseDone({document_id}, {last_section_id:?})")
}
Event::Parsed(document_id, section) => {
write!(
f,
"Event::Parsed({document_id}, id:{}, content: {})",
section.id, section.content
)
}
Event::ImageLoaded(document_id, section_id, url, _) => {
write!(f, "Event::ImageLoaded({document_id}, {section_id}, {url})")
}
Event::ImageFailed(document_id, section_id, url, error) => {
write!(
f,
"Event::ImageFailed({document_id}, {section_id}, {url}, {error})"
)
}
Event::HeaderLoaded(document_id, section_id, rows) => {
write!(
f,
"Event::HeaderLoaded({document_id}, {section_id}, {})",
rows.first()
.map(|(text, _, _)| text.clone())
.unwrap_or_default()
)
}
Event::ReferenceDefinition { id, url } => {
write!(f, "Event::ReferenceDefinition {{ id: {id}, url: {url} }}")
}
Event::FileChanged => write!(f, "Event::FileChanged"),
Event::Scroll(s) => write!(f, "Event::Scroll({s})"),
Event::NewSourceContent(_) => write!(f, "Event::NewSource"),
}
}
}
impl std::fmt::Debug for Event {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(self, f)
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use std::{sync::mpsc, thread::JoinHandle};
#[cfg(not(target_os = "macos"))]
use insta::assert_snapshot;
use ratatui::{Terminal, backend::TestBackend, layout::Size, text::Line};
use ratatui_image::picker::{Picker, ProtocolType};
use crate::{
Cmd, Event,
config::{Config, UserConfig},
document::{Section, SectionContent},
error::Error,
model::Model,
sources::SharedDocumentSource,
view::view,
worker::worker_thread,
};
#[ctor::ctor]
fn init_logger() {
crate::debug::init_test_logger();
}
fn setup(config: Config) -> (Model, JoinHandle<Result<(), Error>>, Terminal<TestBackend>) {
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let (event_tx, event_rx) = mpsc::channel::<Event>();
let picker = Picker::halfblocks();
assert_eq!(picker.protocol_type(), ProtocolType::Halfblocks);
let mut worker_theme = config.theme.clone();
worker_theme.has_text_size_protocol = Some(true);
let document_source = SharedDocumentSource::test();
let worker = worker_thread(
document_source.clone(),
picker,
None,
worker_theme,
false,
cmd_rx,
event_tx,
config.max_image_height,
);
let screen_size = (80, 20).into();
let model = Model::new(document_source, cmd_tx, event_rx, screen_size, config);
let terminal =
Terminal::new(TestBackend::new(screen_size.width, screen_size.height)).unwrap();
(model, worker, terminal)
}
fn teardown(model: Model, worker: JoinHandle<Result<(), Error>>) {
drop(model);
worker.join().unwrap().unwrap();
}
#[track_caller]
fn poll_parsed(model: &mut Model) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
loop {
let (_, parse_done, _) = model.process_events().unwrap();
if parse_done {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for process_events to be done"
);
std::thread::sleep(std::time::Duration::from_millis(1));
}
log::debug!("poll_parsed completed");
}
fn poll_images_done(model: &mut Model) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
while model.has_pending_images() {
model.process_events().unwrap();
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for has_pending_images to be done"
);
std::thread::sleep(std::time::Duration::from_millis(1));
}
log::debug!("poll_done completed");
}
#[test]
fn parse() {
let config = UserConfig {
max_image_height: Some(10),
..Default::default()
}
.into();
let (mut model, worker, mut terminal) = setup(config);
model
.open(String::from(
r#"# Hello
This is a *test* markdown document.
Another line of same paragraph.

# Another header
Some text
# Last bit
Goodbye."#,
))
.unwrap();
poll_parsed(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("first parse image previews", terminal.backend());
poll_images_done(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("first parse done", terminal.backend());
teardown(model, worker);
}
#[test]
fn reload_move_image() {
let config = UserConfig {
max_image_height: Some(10),
..Default::default()
}
.into();
let (mut model, worker, mut terminal) = setup(config);
model
.open(String::from(
r#"# Hello
This is a test markdown document.

Goodbye."#,
))
.unwrap();
poll_parsed(&mut model);
poll_images_done(&mut model);
model
.reparse(
String::from(
r#"# Hello

This is a test markdown document.
Goodbye."#,
),
model.screen_size.width,
)
.unwrap();
poll_parsed(&mut model);
log::debug!("poll_parsed before failing done");
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("reload move image up", terminal.backend());
model
.reparse(
String::from(
r#"# Hello
This is a test markdown document.

Goodbye."#,
),
model.screen_size.width,
)
.unwrap();
poll_parsed(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("reload move image down", terminal.backend());
teardown(model, worker);
}
#[test]
fn reload_add_image() {
let config = UserConfig {
max_image_height: Some(10),
..Default::default()
}
.into();
let (mut model, worker, mut terminal) = setup(config);
model
.open(String::from(
r#"# Hello
This is a test markdown document.

Goodbye."#,
))
.unwrap();
poll_parsed(&mut model);
poll_images_done(&mut model);
model
.reparse(
String::from(
r#"# Hello
This is a test markdown document.


Goodbye."#,
),
model.screen_size.width,
)
.unwrap();
poll_parsed(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("reload add image preview", terminal.backend());
poll_images_done(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("reload add image done", terminal.backend());
teardown(model, worker);
}
#[test]
fn duplicate_image() {
let config = UserConfig {
max_image_height: Some(8),
..Default::default()
}
.into();
let (mut model, worker, mut terminal) = setup(config);
model
.open(String::from(
r#"# Hello

Goodbye."#,
))
.unwrap();
poll_parsed(&mut model);
poll_images_done(&mut model);
model
.reparse(
String::from(
r#"# Hello

Goodbye.
"#,
),
model.screen_size.width,
)
.unwrap();
poll_parsed(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("duplicate image preview", terminal.backend());
poll_images_done(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
#[cfg(not(target_os = "macos"))]
assert_snapshot!("duplicate image done", terminal.backend());
teardown(model, worker);
}
#[test]
fn simple_resize() {
let config = UserConfig {
max_image_height: Some(10),
..Default::default()
}
.into();
let (mut model, worker, mut terminal) = setup(config);
model.screen_size = Size::new(40, 20);
model
.open(String::from(
r#"# Header here hee hee heeeeeeeeeeeeee
Line that should be broken up later
"#,
))
.unwrap();
poll_parsed(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
let sections: Vec<&Section> = model.sections().collect();
assert_eq!(3, sections.len());
assert_eq!(
SectionContent::Header("Header here hee hee".to_owned(), 1, None),
sections[0].content
);
assert_eq!(
SectionContent::Header("heeeeeeeeeeeeee".to_owned(), 1, None),
sections[1].content
);
assert_eq!(
SectionContent::Lines(vec![(
Line::from("Line that should be broken up later"),
Vec::new()
),]),
sections[2].content
);
model.reload(Size::new(20, 20)).unwrap();
poll_parsed(&mut model);
terminal
.draw(|frame| {
let cursor_position = view(&model, frame.buffer_mut());
frame.set_cursor_position(cursor_position);
})
.unwrap();
let sections: Vec<&Section> = model.sections().collect();
assert_eq!(6, sections.len());
assert_eq!(
SectionContent::Header("Header".to_owned(), 1, None),
sections[0].content
);
assert_eq!(
SectionContent::Header("here".to_owned(), 1, None),
sections[1].content
);
assert_eq!(
SectionContent::Header("hee hee".to_owned(), 1, None),
sections[2].content
);
assert_eq!(
SectionContent::Header("heeeeeeeee".to_owned(), 1, None),
sections[3].content
);
assert_eq!(
SectionContent::Header("eeeee".to_owned(), 1, None),
sections[4].content
);
assert_eq!(
SectionContent::Lines(vec![
(Line::from("Line that should be"), Vec::new()),
(Line::from("broken up later"), Vec::new()),
]),
sections[5].content
);
teardown(model, worker);
}
}