mod app;
mod clipboard;
mod config;
mod diff;
#[cfg(test)]
mod docs_sync;
mod export;
mod finder;
mod fs;
mod help;
mod image;
mod input;
mod render;
mod text;
mod transclude;
mod tree;
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "shoin", version, about = "A no-clutter terminal Markdown editor")]
struct Cli {
file: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long = "read-only", short = 'R')]
read_only: bool,
#[arg(long)]
zen: bool,
#[arg(long)]
print_config: bool,
#[arg(long)]
init_config: bool,
#[arg(long)]
force: bool,
#[arg(long, value_name = "FILE")]
export: Option<PathBuf>,
#[arg(long, value_name = "FORMAT", default_value = "md")]
format: String,
#[arg(long)]
justify: bool,
#[arg(long, value_name = "PATH")]
out: Option<PathBuf>,
#[arg(long, short = 'p', value_name = "FILE", num_args = 0..=1)]
render: Option<Option<PathBuf>>,
#[arg(long, requires = "render")]
pager: bool,
#[arg(long)]
stdout: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let explicit = cli.config.clone();
if cli.init_config {
return match &explicit {
Some(dir) => config::init::seed(dir, cli.force).map(|done| {
println!("wrote {} files to {}", done.written.len(), done.dir.display());
}),
None => config::init::run(cli.force),
};
}
let config = config::load(explicit.as_deref())?;
if cli.print_config {
println!("{config:#?}");
return Ok(());
}
if let Some(src) = &cli.render {
use std::io::IsTerminal;
let src = src.clone().or_else(|| cli.file.clone());
if src.is_none() && std::io::stdin().is_terminal() {
anyhow::bail!("--render needs a FILE, or text piped in");
}
let src = src.unwrap_or_else(|| PathBuf::from("-"));
let src = &src;
let piped = if is_stdin(src) { Some(read_stdin()?) } else { None };
if piped.is_none() && !src.is_file() {
anyhow::bail!("{}: no such file", src.display());
}
let tty = std::io::stdout().is_terminal();
let colour = tty && std::env::var_os("NO_COLOR").is_none_or(|v| v.is_empty());
let (width, height) = ratatui::crossterm::terminal::size().unwrap_or((80, 24));
let paged = |text: &str| cli.pager && tty && text.lines().count() > height as usize;
let text = if colour {
let mut app = match &piped {
Some(bytes) => {
let mut app = app::App::new(config, None, explicit)?;
app.open_unnamed(text::buffer::Buffer::unnamed(bytes.clone())?);
app
}
None => app::App::new(config, Some(src.clone()), explicit)?,
};
let mut text = render::print::styled(&mut app, width);
if paged(&text) {
app.image_protocol = image::Protocol::None;
text = render::print::styled(&mut app, width);
}
text
} else {
let flat = match &piped {
Some(bytes) => {
let text = String::from_utf8(bytes.clone())
.map_err(|_| anyhow::anyhow!("stdin is not valid UTF-8"))?;
let here = std::env::current_dir()?.join("stdin.md");
transclude::compile::compile_text(&text, &here, &config.transclude)?
}
None => transclude::compile::compile(src, &config.transclude)?,
};
export::plain::render(&flat, config.layout.measure, false)
};
if !(paged(&text) && render::print::page(&text)) {
print_out(&text)?;
}
return Ok(());
}
if let Some(src) = &cli.export {
let format = export::Format::parse(&cli.format)
.ok_or_else(|| anyhow::anyhow!("{:?} is not md, txt, html or pdf", cli.format))?;
if cli.justify && !matches!(format, export::Format::Html | export::Format::Text) {
anyhow::bail!("--justify only applies to --format html or --format txt");
}
let page = export::html::Page {
theme: render::theme::Theme::authored(&config.theme).unwrap_or_default(),
measure: config.layout.measure,
base: src.parent().unwrap_or(std::path::Path::new("")).to_path_buf(),
justify: cli.justify,
};
let title = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "document".into());
if cli.stdout {
if format == export::Format::Pdf {
anyhow::bail!("--stdout cannot print a PDF; give --out a path");
}
let flat = transclude::compile::compile(src, &config.transclude)?;
match format {
export::Format::Text => print_out(&export::plain::render(&flat, page.measure, page.justify))?,
export::Format::Html => print_out(&export::html::render(&flat, &title, &page))?,
_ => print_out(&flat)?,
}
return Ok(());
}
let dest = cli
.out
.clone()
.unwrap_or_else(|| export::default_path(src, format));
export::write(src, &dest, format, &config.transclude, &page)?;
eprintln!("{}", dest.display());
return Ok(());
}
let mouse = config.input.mouse;
use std::io::IsTerminal;
let piped = match &cli.file {
Some(f) if is_stdin(f) => true,
None => !std::io::stdin().is_terminal(),
Some(_) => false,
};
let mut app = if piped {
let bytes = read_stdin()?;
let mut app = app::App::new(config, None, explicit)?;
app.open_unnamed(text::buffer::Buffer::unnamed(bytes)?);
app
} else {
app::App::new(config, cli.file, explicit)?
};
if cli.zen {
app.set_zen(true);
}
if cli.read_only {
app.set_view(true);
}
let mut terminal = ratatui::init();
use ratatui::crossterm::event::{
EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
if mouse {
let _ = ratatui::crossterm::execute!(std::io::stdout(), EnableMouseCapture);
}
let _ = ratatui::crossterm::execute!(std::io::stdout(), EnableFocusChange);
let _ = ratatui::crossterm::execute!(std::io::stdout(), EnableBracketedPaste);
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
let restore_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
release_terminal_modes();
restore_hook(info);
}));
let result = app.run_loop(&mut terminal);
release_terminal_modes();
ratatui::restore();
result
}
fn release_terminal_modes() {
use ratatui::crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture,
PopKeyboardEnhancementFlags,
};
let _ = ratatui::crossterm::execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
let _ = ratatui::crossterm::execute!(std::io::stdout(), DisableBracketedPaste);
let _ = ratatui::crossterm::execute!(std::io::stdout(), DisableFocusChange);
let _ = ratatui::crossterm::execute!(std::io::stdout(), DisableMouseCapture);
}
fn is_stdin(path: &std::path::Path) -> bool {
path.as_os_str() == "-"
}
fn read_stdin() -> Result<Vec<u8>> {
use std::io::Read;
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes)?;
Ok(bytes)
}
fn print_out(text: &str) -> Result<()> {
use std::io::Write;
let mut out = std::io::stdout().lock();
match out.write_all(text.as_bytes()).and_then(|()| out.flush()) {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
other => Ok(other?),
}
}