blob: b0ce01548f00febedf5847b6fa04330a06d7b9a3 [file] [log] [blame]
use std::cell::Cell;
use std::io::{self, Write};
use anstyle::{AnsiColor, Effects, Style};
use crate::markdown::{MdStream, MdTree};
const DEFAULT_COLUMN_WIDTH: usize = 140;
thread_local! {
/// Track the position of viewable characters in our buffer
static CURSOR: Cell<usize> = const { Cell::new(0) };
/// Width of the terminal
static WIDTH: Cell<usize> = const { Cell::new(DEFAULT_COLUMN_WIDTH) };
}
/// Print to terminal output to a buffer
pub(crate) fn entrypoint(stream: &MdStream<'_>, buf: &mut Vec<u8>) -> io::Result<()> {
#[cfg(not(test))]
if let Some((w, _)) = termize::dimensions() {
WIDTH.set(std::cmp::min(w, DEFAULT_COLUMN_WIDTH));
}
write_stream(stream, buf, None, 0)?;
buf.write_all(b"\n")
}
/// Write the buffer, reset to the default style after each
fn write_stream(
MdStream(stream): &MdStream<'_>,
buf: &mut Vec<u8>,
default: Option<Style>,
indent: usize,
) -> io::Result<()> {
for tt in stream {
write_tt(tt, buf, default, indent)?;
}
reset_opt_style(buf, default)?;
Ok(())
}
fn write_tt(
tt: &MdTree<'_>,
buf: &mut Vec<u8>,
default: Option<Style>,
indent: usize,
) -> io::Result<()> {
match tt {
MdTree::CodeBlock { txt, lang: _ } => {
reset_opt_style(buf, default)?;
let style = Style::new().effects(Effects::DIMMED);
write!(buf, "{style}{txt}{style:#}")?;
render_opt_style(buf, default)?;
}
MdTree::CodeInline(txt) => {
reset_opt_style(buf, default)?;
write_wrapping(buf, txt, indent, None, Some(Style::new().effects(Effects::DIMMED)))?;
render_opt_style(buf, default)?;
}
MdTree::Strong(txt) => {
reset_opt_style(buf, default)?;
write_wrapping(buf, txt, indent, None, Some(Style::new().effects(Effects::BOLD)))?;
render_opt_style(buf, default)?;
}
MdTree::Emphasis(txt) => {
reset_opt_style(buf, default)?;
write_wrapping(buf, txt, indent, None, Some(Style::new().effects(Effects::ITALIC)))?;
render_opt_style(buf, default)?;
}
MdTree::Strikethrough(txt) => {
reset_opt_style(buf, default)?;
write_wrapping(
buf,
txt,
indent,
None,
Some(Style::new().effects(Effects::STRIKETHROUGH)),
)?;
render_opt_style(buf, default)?;
}
MdTree::PlainText(txt) => {
write_wrapping(buf, txt, indent, None, None)?;
}
MdTree::Link { disp, link } => {
write_wrapping(buf, disp, indent, Some(link), None)?;
}
MdTree::ParagraphBreak => {
buf.write_all(b"\n\n")?;
reset_cursor();
}
MdTree::LineBreak => {
buf.write_all(b"\n")?;
reset_cursor();
}
MdTree::HorizontalRule => {
(0..WIDTH.get()).for_each(|_| buf.write_all(b"-").unwrap());
reset_cursor();
}
MdTree::Heading(n, stream) => {
let cs = match n {
1 => AnsiColor::BrightCyan.on_default().effects(Effects::BOLD | Effects::UNDERLINE),
2 => AnsiColor::BrightCyan.on_default().effects(Effects::UNDERLINE),
3 => AnsiColor::BrightCyan.on_default().effects(Effects::ITALIC),
4.. => AnsiColor::Cyan.on_default().effects(Effects::UNDERLINE | Effects::ITALIC),
0 => unreachable!(),
};
reset_opt_style(buf, default)?;
write!(buf, "{cs}")?;
write_stream(stream, buf, Some(cs), 0)?;
write!(buf, "{cs:#}")?;
render_opt_style(buf, default)?;
buf.write_all(b"\n")?;
}
MdTree::OrderedListItem(n, stream) => {
let base = format!("{n}. ");
write_wrapping(buf, &format!("{base:<4}"), indent, None, None)?;
write_stream(stream, buf, None, indent + 4)?;
}
MdTree::UnorderedListItem(stream) => {
let base = "* ";
write_wrapping(buf, &format!("{base:<4}"), indent, None, None)?;
write_stream(stream, buf, None, indent + 4)?;
}
// Patterns popped in previous step
MdTree::Comment(_) | MdTree::LinkDef { .. } | MdTree::RefLink { .. } => unreachable!(),
}
Ok(())
}
fn render_opt_style(buf: &mut Vec<u8>, style: Option<Style>) -> io::Result<()> {
if let Some(style) = &style {
write!(buf, "{style}")?;
}
Ok(())
}
fn reset_opt_style(buf: &mut Vec<u8>, style: Option<Style>) -> io::Result<()> {
if let Some(style) = &style {
write!(buf, "{style:#}")?;
}
Ok(())
}
/// End of that block, just wrap the line
fn reset_cursor() {
CURSOR.set(0);
}
/// Change to be generic on Write for testing. If we have a link URL, we don't
/// count the extra tokens to make it clickable.
fn write_wrapping(
buf: &mut Vec<u8>,
text: &str,
indent: usize,
link_url: Option<&str>,
style: Option<Style>,
) -> io::Result<()> {
render_opt_style(buf, style)?;
let ind_ws = &b" "[..indent];
let mut to_write = text;
if let Some(url) = link_url {
// This is a nonprinting prefix so we don't increment our cursor
write!(buf, "\x1b]8;;{url}\x1b\\")?;
}
CURSOR.with(|cur| {
loop {
if cur.get() == 0 {
buf.write_all(ind_ws)?;
cur.set(indent);
}
let ch_count = WIDTH.get() - cur.get();
let mut iter = to_write.char_indices();
let Some((end_idx, _ch)) = iter.nth(ch_count) else {
// Write entire line
buf.write_all(to_write.as_bytes())?;
cur.set(cur.get() + to_write.chars().count());
break;
};
if let Some((break_idx, ch)) = to_write[..end_idx]
.char_indices()
.rev()
.find(|(_idx, ch)| ch.is_whitespace() || ['_', '-'].contains(ch))
{
// Found whitespace to break at
if ch.is_whitespace() {
writeln!(buf, "{}", &to_write[..break_idx])?;
to_write = to_write[break_idx..].trim_start();
} else {
// Break at a `-` or `_` separator
writeln!(buf, "{}", &to_write.get(..break_idx + 1).unwrap_or(to_write))?;
to_write = to_write.get(break_idx + 1..).unwrap_or_default().trim_start();
}
} else {
// No whitespace, we need to just split
let ws_idx =
iter.find(|(_, ch)| ch.is_whitespace()).map_or(to_write.len(), |(idx, _)| idx);
writeln!(buf, "{}", &to_write[..ws_idx])?;
to_write = to_write.get(ws_idx + 1..).map_or("", str::trim_start);
}
cur.set(0);
}
if link_url.is_some() {
buf.write_all(b"\x1b]8;;\x1b\\")?;
}
reset_opt_style(buf, style)?;
Ok(())
})
}
#[cfg(test)]
#[path = "tests/term.rs"]
mod tests;