use std::collections::HashMap;
use std::fmt::Write as _;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
use ratatui::Terminal;
use unicode_width::UnicodeWidthStr;
use crate::app::App;
use crate::render::conceal::ActiveSet;
use crate::render::numbers::NumberMode;
pub fn styled(app: &mut App, width: u16) -> String {
app.active = ActiveSet::none();
app.config.status.enabled = false;
app.config.layout.scroll_hint = false;
app.numbers = NumberMode::Off;
app.embed_mode = crate::transclude::Mode::Full;
draw(app, width, 1);
let rows = app.docs[app.current()].cache.borrow().total_rows();
let pad = app.config.layout.padding_top + app.config.layout.padding_bottom;
let height = (rows + pad as usize).clamp(1, u16::MAX as usize) as u16;
let buf = draw(app, width, height);
let top = app.config.layout.padding_top.min(height / 4);
let hidden = top..top.saturating_add(front_matter_rows(app));
ansi(&buf, app.theme.background.to_ratatui(), &pictures(app), hidden)
}
fn front_matter_rows(app: &App) -> u16 {
let b = &app.buffer;
let text: String = (0..b.line_count()).map(|l| b.line_text(l) + "\n").collect();
let body = crate::transclude::link::strip_front_matter(&text);
let lines = text[..text.len() - body.len()].matches('\n').count();
if lines == 0 {
return 0;
}
let cache = app.cache.borrow();
let rows = if lines < b.line_count() { cache.locate(lines, 0).0 } else { cache.total_rows() };
rows.min(u16::MAX as usize) as u16
}
fn pictures(app: &App) -> HashMap<u16, String> {
let mut out: HashMap<u16, String> = HashMap::new();
let places = std::mem::take(&mut *app.images.borrow_mut());
if app.image_protocol == crate::image::Protocol::None {
return out;
}
let cache = app.cache.borrow();
for p in places {
let Some(crate::transclude::preview::Row::Image(pic)) = cache
.entry(p.line)
.and_then(|e| e.embed.as_ref())
.and_then(|x| x.rows.get(p.index))
else {
continue;
};
if pic.payload.is_empty() || p.rows == 0 {
continue;
}
let up = p.rows - 1;
let s = out.entry(p.y + up).or_default();
s.push_str("\x1b7");
if up > 0 {
let _ = write!(s, "\x1b[{up}A");
}
s.push('\r');
if p.x > 0 {
let _ = write!(s, "\x1b[{}C", p.x);
}
s.push_str(&crate::image::draw(app.image_protocol, &pic.payload, p.cols, p.rows));
s.push_str("\x1b8");
}
out
}
pub fn page(text: &str) -> bool {
use std::io::Write;
use std::process::{Command, Stdio};
let pager = std::env::var("PAGER")
.ok()
.filter(|p| !p.trim().is_empty())
.unwrap_or_else(|| "less -R".into());
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&pager).stdin(Stdio::piped());
if std::env::var_os("LESS").is_none() {
cmd.env("LESS", "R");
}
let Ok(mut child) = cmd.spawn() else {
return false;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
let _ = child.wait();
true
}
fn draw(app: &App, width: u16, height: u16) -> Buffer {
let mut terminal = Terminal::new(TestBackend::new(width.max(1), height)).expect("an in-memory terminal");
terminal
.draw(|f| crate::render::frame::render(f, app))
.expect("drawing into memory");
terminal.backend().buffer().clone()
}
fn ansi(
buf: &Buffer,
page_bg: Color,
after_row: &HashMap<u16, String>,
hidden: std::ops::Range<u16>,
) -> String {
let bg_of = |c: Color| if c == page_bg { Color::Reset } else { c };
let w = buf.area.width;
let mut lines: Vec<String> = Vec::with_capacity(buf.area.height as usize);
for y in (0..buf.area.height).filter(|y| !hidden.contains(y)) {
let end = (0..w)
.rev()
.find(|&x| {
let c = &buf[(x, y)];
!c.symbol().trim().is_empty() || bg_of(c.bg) != Color::Reset
})
.map_or(0, |x| x + 1);
let mut out = String::new();
let mut current: Option<(Color, Color, Modifier)> = None;
let mut x = 0;
while x < end {
let c = &buf[(x, y)];
let style = (c.fg, bg_of(c.bg), c.modifier);
if current != Some(style) {
out.push_str(&sgr(style.0, style.1, style.2));
current = Some(style);
}
out.push_str(c.symbol());
x += c.symbol().width().max(1) as u16;
}
if current.is_some() {
out.push_str("\x1b[0m");
}
if let Some(paint) = after_row.get(&y) {
out.push_str(paint);
}
lines.push(out);
}
let first = lines.iter().position(|l| !l.is_empty()).unwrap_or(lines.len());
let last = lines.iter().rposition(|l| !l.is_empty()).map_or(first, |i| i + 1);
let mut text = lines[first..last].join("\n");
text.push('\n');
text
}
pub(crate) fn sgr(fg: Color, bg: Color, m: Modifier) -> String {
let mut codes = vec!["0".to_string()];
for (bit, code) in [
(Modifier::BOLD, "1"),
(Modifier::DIM, "2"),
(Modifier::ITALIC, "3"),
(Modifier::UNDERLINED, "4"),
(Modifier::REVERSED, "7"),
(Modifier::CROSSED_OUT, "9"),
] {
if m.contains(bit) {
codes.push(code.into());
}
}
if let Some(c) = color(fg, false) {
codes.push(c);
}
if let Some(c) = color(bg, true) {
codes.push(c);
}
let mut s = String::from("\x1b[");
let _ = write!(s, "{}m", codes.join(";"));
s
}
fn color(c: Color, bg: bool) -> Option<String> {
let base = if bg { 40 } else { 30 };
Some(match c {
Color::Reset => return None,
Color::Rgb(r, g, b) => format!("{};2;{r};{g};{b}", base + 8),
Color::Indexed(i) => format!("{};5;{i}", base + 8),
Color::Black => format!("{}", base),
Color::Red => format!("{}", base + 1),
Color::Green => format!("{}", base + 2),
Color::Yellow => format!("{}", base + 3),
Color::Blue => format!("{}", base + 4),
Color::Magenta => format!("{}", base + 5),
Color::Cyan => format!("{}", base + 6),
Color::Gray => format!("{}", base + 7),
Color::DarkGray => format!("{}", base + 60),
Color::LightRed => format!("{}", base + 61),
Color::LightGreen => format!("{}", base + 62),
Color::LightYellow => format!("{}", base + 63),
Color::LightBlue => format!("{}", base + 64),
Color::LightMagenta => format!("{}", base + 65),
Color::LightCyan => format!("{}", base + 66),
Color::White => format!("{}", base + 67),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::render::markdown::block::BlockCache;
use crate::text::cursor::Cursor;
fn app_with(text: &str) -> App {
let mut app = App::new(Config::default(), None, None).unwrap();
app.theme = crate::render::theme::Theme::authored(&app.config.theme).unwrap();
app.buffer.insert_str(Cursor::new(0, 0), text);
app.blocks = BlockCache::build(&app.buffer);
app
}
fn strip(s: &str) -> String {
let mut out = String::new();
let mut esc = false;
for ch in s.chars() {
match (esc, ch) {
(false, '\x1b') => esc = true,
(true, 'm') => esc = false,
(true, _) => {}
(false, c) => out.push(c),
}
}
out
}
#[test]
fn every_line_is_concealed() {
let mut app = app_with("# Title\n\nSome **bold** text.\n");
let plain = strip(&styled(&mut app, 60));
assert!(!plain.contains("**"), "{plain}");
assert!(!plain.contains("# "), "{plain}");
assert!(plain.contains("Title") && plain.contains("bold"), "{plain}");
}
#[test]
fn a_long_document_prints_every_line() {
let text: String = (0..300).map(|i| format!("line {i}\n\n")).collect();
let mut app = app_with(&text);
let plain = strip(&styled(&mut app, 60));
assert!(plain.contains("line 0") && plain.contains("line 299"));
assert!(!plain.starts_with('\n') && !plain.ends_with("\n\n"), "trimmed at both ends");
}
#[test]
fn embeds_expand_and_pictures_are_painted() {
let dir = std::env::temp_dir().join(format!("shoin-print-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut png = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1F, 0x15, 0xC4, 0x89,
];
png[16..20].copy_from_slice(&400u32.to_be_bytes());
png[20..24].copy_from_slice(&200u32.to_be_bytes());
std::fs::write(dir.join("photo.png"), &png).unwrap();
std::fs::write(dir.join("other.md"), "The embedded sentence.\n").unwrap();
let main = dir.join("main.md");
std::fs::write(&main, "Before.\n\n![[other]]\n\n![[photo.png]]\n\nAfter.\n").unwrap();
let mut app = App::new(Config::default(), Some(main), None).unwrap();
app.image_protocol = crate::image::Protocol::Kitty;
let out = styled(&mut app, 80);
let plain = strip(&out);
assert!(plain.contains("The embedded sentence."), "{plain}");
assert!(!plain.contains("![["), "{plain}");
assert!(plain.contains("After."), "{plain}");
assert_eq!(out.matches("\x1b_Ga=T").count(), 1, "one picture, painted once");
assert!(out.contains("\x1b7") && out.contains("\x1b8"), "the cursor is put back");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn front_matter_is_left_out() {
let mut app = app_with("---\ntitle: Secret\ntags: [a]\n---\n\n# Heading\n\nBody.\n");
let plain = strip(&styled(&mut app, 60));
assert!(!plain.contains("title") && !plain.contains("---"), "{plain}");
assert!(plain.trim_start().starts_with("Heading"), "{plain}");
assert!(plain.contains("Body."), "{plain}");
let mut app = app_with("Para one.\n\n---\n\nPara two.\n");
let plain = strip(&styled(&mut app, 60));
assert!(plain.trim_start().starts_with("Para one.") && plain.contains("Para two."), "{plain}");
}
#[test]
fn styles_become_sgr_and_the_page_background_does_not() {
let mut app = app_with("Some **bold** text.\n");
let out = styled(&mut app, 60);
assert!(out.contains("\x1b[0;1;"), "bold reaches the output: {out:?}");
let bg = match app.theme.background.to_ratatui() {
Color::Rgb(r, g, b) => format!("48;2;{r};{g};{b}"),
other => panic!("authored theme is RGB, got {other:?}"),
};
assert!(!out.contains(&bg), "the page background is not painted");
}
}