| 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 the terminal output to a buffer | |
| /// optionally with a formatter for code blocks | |
| pub(crate) fn entrypoint( | |
| stream: &MdStream<'_>, | |
| buf: &mut Vec<u8>, | |
| formatter: Option<&(dyn Fn(&str, &mut Vec<u8>) -> io::Result<()> + 'static)>, | |
| ) -> io::Result<()> { | |
| write_stream(stream, buf, None, 0, formatter)?; | |
| buf.write_all(b"\n") | |
| } | |
| /// Write the buffer, reset to the default style after each, | |
| /// optionally with a formatter for code blocks | |
| fn write_stream( | |
| MdStream(stream): &MdStream<'_>, | |
| buf: &mut Vec<u8>, | |
| default: Option<Style>, | |
| indent: usize, | |
| formatter: Option<&(dyn Fn(&str, &mut Vec<u8>) -> io::Result<()> + 'static)>, | |
| ) -> io::Result<()> { | |
| for tt in stream { | |
| write_tt(tt, buf, default, indent, formatter)?; | |
| } | |
| Ok(()) | |
| } | |
| fn write_tt( | |
| tt: &MdTree<'_>, | |
| buf: &mut Vec<u8>, | |
| default: Option<Style>, | |
| indent: usize, | |
| formatter: Option<&(dyn Fn(&str, &mut Vec<u8>) -> io::Result<()> + 'static)>, | |
| ) -> io::Result<()> { | |
| match tt { | |
| MdTree::CodeBlock { txt, lang: _ } => { | |
| reset_opt_style(buf, default)?; | |
| if let Some(formatter) = formatter { | |
| formatter(txt, buf)?; | |
| } else { | |
| 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, None)?; | |
| 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, None)?; | |
| } | |
| MdTree::UnorderedListItem(stream) => { | |
| let base = "* "; | |
| write_wrapping(buf, &format!("{base:<4}"), indent, None, None)?; | |
| write_stream(stream, buf, None, indent + 4, None)?; | |
| } | |
| // 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; |