use std::{
env, fs,
io::{self, Stdout, Write},
panic,
path::{Path, PathBuf},
process,
time::{Duration, Instant},
};
use anyhow::{Context, Result, bail};
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
MouseButton, MouseEventKind,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap},
};
mod graphics;
mod math;
mod render;
const MAX_CONTENT_WIDTH: u16 = 130;
const MIN_CONTENT_WIDTH: u16 = 80;
const DEFAULT_CONTENT_WIDTH: u16 = 90;
const SIDE_MARGIN: u16 = 4;
const SCROLL_STEP: u16 = 1;
const PAGE_STEP: u16 = 10;
const WIDTH_STEP: u16 = 4;
const FRAME_COLOR: Color = Color::DarkGray;
const TITLE_COLOR: Color = Color::Green;
const STATUS_TTL: Duration = Duration::from_secs(2);
const BRAND: &str = " mdview ";
const BRAND_COLOR: Color = Color::Rgb(255, 191, 0);
const SCROLL_THUMB: &str = "▐";
#[derive(Copy, Clone, PartialEq, Eq)]
enum Mode {
Rendered,
Raw,
}
#[derive(PartialEq, Clone, Copy)]
struct Placement {
x: u16,
y: u16,
cells: (u16, u16),
rows: (u16, u16),
}
struct Drawn {
at: Placement,
png: Vec<u8>,
}
struct ImageRows {
click: (usize, usize),
art: Option<(usize, usize)>,
}
struct Status {
text: String,
until: Instant,
error: bool,
}
struct App {
path: PathBuf,
source: String,
rendered: Text<'static>,
images: Vec<render::ImageRef>,
image_rows: Vec<ImageRows>,
content_width: u16,
mode: Mode,
scroll: u16,
raw_line_count: u16,
rendered_line_count: u16,
status: Option<Status>,
hover: Option<usize>,
help: bool,
}
impl App {
fn new(path: PathBuf, source: String) -> Self {
let term_width = crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80);
let content_width = DEFAULT_CONTENT_WIDTH
.min(term_width.saturating_sub(SIDE_MARGIN))
.max(20);
let raw_line_count = visual_line_count(source.as_str(), content_width);
let base = path.parent().unwrap_or(Path::new("")).to_path_buf();
let (rendered, images) = render::render(&source, content_width, &base);
let rendered_line_count = visual_line_count(rendered.clone(), content_width);
let image_rows = row_ranges(&rendered, &images, content_width);
Self {
path,
source,
rendered,
images,
image_rows,
content_width,
mode: Mode::Rendered,
scroll: 0,
raw_line_count,
rendered_line_count,
status: None,
hover: None,
help: false,
}
}
fn line_count(&self) -> u16 {
match self.mode {
Mode::Rendered => self.rendered_line_count,
Mode::Raw => self.raw_line_count,
}
}
fn toggle_mode(&mut self) {
self.hover = None;
self.mode = match self.mode {
Mode::Rendered => Mode::Raw,
Mode::Raw => Mode::Rendered,
};
self.scroll = self.scroll.min(self.line_count().saturating_sub(1));
}
fn adjust_width(&mut self, delta: i32) {
let term_w = crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80);
let max = term_w
.saturating_sub(SIDE_MARGIN)
.min(MAX_CONTENT_WIDTH);
let min = MIN_CONTENT_WIDTH.min(max);
let next = (self.content_width as i32 + delta).clamp(min as i32, max as i32) as u16;
if next == self.content_width {
self.status = Some(Status {
text: format!("width {} (limit)", self.content_width),
until: Instant::now() + STATUS_TTL,
error: false,
});
return;
}
self.content_width = next;
let base = self.path.parent().unwrap_or(Path::new("")).to_path_buf();
let (rendered, images) = render::render(&self.source, self.content_width, &base);
self.rendered = rendered;
self.images = images;
self.image_rows = row_ranges(&self.rendered, &self.images, self.content_width);
self.rendered_line_count = visual_line_count(self.rendered.clone(), self.content_width);
self.raw_line_count = visual_line_count(self.source.as_str(), self.content_width);
self.scroll = self
.scroll
.min(self.rendered_line_count.saturating_sub(1));
self.status = Some(Status {
text: format!("width {}", self.content_width),
until: Instant::now() + STATUS_TTL,
error: false,
});
}
fn scroll_by(&mut self, delta: i32, viewport_height: u16) {
let max = self
.line_count()
.saturating_sub(viewport_height.max(1).saturating_sub(1));
let next = (self.scroll as i32).saturating_add(delta).clamp(0, max as i32);
self.scroll = next as u16;
self.hover = None;
}
fn open_image(&mut self, idx: usize) {
let dest = self.images[idx].dest.clone();
let (text, error) = self.launch_open(&dest);
self.status = Some(Status {
text,
until: Instant::now() + STATUS_TTL,
error,
});
}
fn launch_open(&self, dest: &str) -> (String, bool) {
let arg = if dest.starts_with("http://") || dest.starts_with("https://") {
dest.to_string()
} else {
let p = Path::new(dest);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
self.path.parent().unwrap_or(Path::new("")).join(p)
};
if !resolved.exists() {
return (format!("not found: {dest}"), true);
}
resolved.display().to_string()
};
match process::Command::new("open").arg(&arg).spawn() {
Ok(_) => (format!("opened {dest}"), false),
Err(e) => (format!("open failed: {e}"), true),
}
}
fn open_image_below(&mut self) {
if self.mode != Mode::Rendered {
self.status = Some(Status {
text: "images open from rendered view (tab)".to_string(),
until: Instant::now() + STATUS_TTL,
error: false,
});
return;
}
let scroll = self.scroll as usize;
match self.image_rows.iter().position(|r| r.click.1 > scroll) {
Some(i) => self.open_image(i),
None => {
self.status = Some(Status {
text: "no image below".to_string(),
until: Instant::now() + STATUS_TTL,
error: false,
});
}
}
}
fn image_at(&self, column: u16, row: u16, area: Rect) -> Option<usize> {
if self.mode != Mode::Rendered || !area.contains(Position::new(column, row)) {
return None;
}
let visual = self.scroll as usize + (row - area.y) as usize;
self.image_rows
.iter()
.position(|r| r.click.0 <= visual && visual < r.click.1)
}
fn click(&mut self, column: u16, row: u16, area: Rect) {
if let Some(i) = self.image_at(column, row, area) {
self.open_image(i);
}
}
fn yank_path(&mut self) {
let path = self.path.display().to_string();
let (text, error) = match arboard::Clipboard::new().and_then(|mut c| c.set_text(&path)) {
Ok(()) => (format!("copied {path}"), false),
Err(e) => (format!("clipboard error: {e}"), true),
};
self.status = Some(Status {
text,
until: Instant::now() + STATUS_TTL,
error,
});
}
fn current_status(&self) -> Option<&Status> {
self.status
.as_ref()
.filter(|s| Instant::now() < s.until)
}
}
fn main() {
if let Err(err) = run() {
eprintln!("mdview: {err:#}");
process::exit(1);
}
}
fn run() -> Result<()> {
let path = parse_args()?;
let source = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut app = App::new(path, source);
let mut terminal = setup_terminal()?;
let result = event_loop(&mut terminal, &mut app);
restore_terminal()?;
result
}
fn parse_args() -> Result<PathBuf> {
let mut args = env::args_os().skip(1);
let Some(arg) = args.next() else {
bail!("usage: mdview <file.md> (try --help)");
};
match arg.to_str() {
Some("-h" | "-H" | "--help") => {
print_help();
process::exit(0);
}
Some("-V" | "-v" | "--version") => {
println!("mdview {}", env!("CARGO_PKG_VERSION"));
process::exit(0);
}
Some("--licenses") => {
print_licenses();
process::exit(0);
}
Some(other) if other.starts_with('-') => {
bail!("unrecognized argument '{other}' (try --help)");
}
_ => {}
}
if args.next().is_some() {
bail!("usage: mdview <file.md> (try --help)");
}
Ok(PathBuf::from(arg))
}
fn print_help() {
println!(
"mdview {version}
A minimal terminal markdown reader.
USAGE:
mdview <file.md>
OPTIONS:
-h, --help Print this help
-V, --version Print version
--licenses Print licensing
Press ? inside the app for keyboard shortcuts.",
version = env!("CARGO_PKG_VERSION"),
);
}
fn print_licenses() {
println!(
"mdview-tui {version} — MIT.
Copyright (c) 2026 Ivapo
https://github.com/Ivapo/mdview/blob/main/LICENSE
Every file in this crate is mdview's own work. Nothing is vendored, no fonts or other
assets are embedded, and what it renders is your file — so nothing of mdview's ends up
inside anything it produces.
Its dependencies are permissive throughout (MIT, Apache-2.0, BSD, Zlib, Unlicense), with
no copyleft anywhere in the tree. Their texts are not reproduced here: this crate ships
as source, and Cargo.toml names every direct dependency.
This records what the binary contains and is not legal advice.",
version = env!("CARGO_PKG_VERSION"),
);
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let _ = restore_terminal();
original_hook(info);
}));
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("enter alternate screen")?;
Terminal::new(CrosstermBackend::new(stdout)).context("create terminal")
}
fn restore_terminal() -> Result<()> {
let mut stdout = io::stdout();
let _ = execute!(stdout, DisableMouseCapture, LeaveAlternateScreen);
let _ = disable_raw_mode();
Ok(())
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
) -> Result<()> {
let inline_images = graphics::supported();
let mut placed: Vec<Option<Drawn>> = vec![];
let mut content_area = Rect::default();
loop {
terminal.draw(|frame| {
content_area = draw(frame, app);
})?;
if inline_images {
draw_inline_images(terminal.backend_mut(), app, content_area, &mut placed)?;
}
let viewport_height = content_area.height;
if let Some(until) = app.current_status().map(|s| s.until) {
if !event::poll(until.saturating_duration_since(Instant::now()))? {
continue;
}
}
let mut ev = event::read()?;
loop {
if handle_event(ev, app, viewport_height, content_area, &mut placed) {
return Ok(());
}
if !event::poll(Duration::ZERO)? {
break;
}
ev = event::read()?;
}
}
}
fn handle_event(
ev: Event,
app: &mut App,
viewport_height: u16,
content_area: Rect,
placed: &mut Vec<Option<Drawn>>,
) -> bool {
match ev {
Event::Key(key) if key.kind == KeyEventKind::Press && app.help => {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('?')
) {
app.help = false;
}
}
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('?') => app.help = true,
KeyCode::Tab => app.toggle_mode(),
KeyCode::Char('y') => app.yank_path(),
KeyCode::Char('o') => app.open_image_below(),
KeyCode::Char('-') => app.adjust_width(-(WIDTH_STEP as i32)),
KeyCode::Char('+') | KeyCode::Char('=') => {
app.adjust_width(WIDTH_STEP as i32)
}
KeyCode::Char('j') | KeyCode::Down => {
app.scroll_by(SCROLL_STEP as i32, viewport_height)
}
KeyCode::Char('k') | KeyCode::Up => {
app.scroll_by(-(SCROLL_STEP as i32), viewport_height)
}
KeyCode::PageDown | KeyCode::Char(' ') => {
app.scroll_by(PAGE_STEP as i32, viewport_height)
}
KeyCode::PageUp => app.scroll_by(-(PAGE_STEP as i32), viewport_height),
KeyCode::Home | KeyCode::Char('g') => app.scroll = 0,
KeyCode::End | KeyCode::Char('G') => {
app.scroll_by(i32::MAX, viewport_height)
}
_ => {}
},
Event::Mouse(m) if !app.help => match m.kind {
MouseEventKind::ScrollDown => {
app.scroll_by(3, viewport_height);
}
MouseEventKind::ScrollUp => {
app.scroll_by(-3, viewport_height);
}
MouseEventKind::Down(MouseButton::Left) => {
app.click(m.column, m.row, content_area);
}
MouseEventKind::Moved => {
app.hover = app.image_at(m.column, m.row, content_area);
}
_ => {}
},
Event::Resize(..) => placed.clear(),
_ => {}
}
false
}
fn draw_inline_images<W: Write>(
out: &mut W,
app: &App,
area: Rect,
last: &mut Vec<Option<Drawn>>,
) -> io::Result<()> {
if app.mode != Mode::Rendered || app.help || area.height == 0 {
last.clear();
return Ok(());
}
last.resize_with(app.images.len(), || None);
let top = app.scroll as usize;
let bottom = top + area.height as usize;
let mut drawn = false;
for (i, (img, rows)) in app.images.iter().zip(&app.image_rows).enumerate() {
let (Some(art), Some((start, end))) = (img.art.as_ref(), rows.art) else {
continue;
};
let (vis_start, vis_end) = (start.max(top), end.min(bottom));
if vis_end <= vis_start {
last[i] = None;
continue;
}
let at = Placement {
x: area.x + art.pad,
y: area.y + (vis_start - top) as u16,
cells: art.cells,
rows: ((vis_start - start) as u16, (vis_end - vis_start) as u16),
};
let previous = last[i].take();
if previous.as_ref().is_some_and(|d| d.at == at) {
last[i] = previous;
continue;
}
let png = match previous {
Some(d) if (d.at.cells, d.at.rows) == (at.cells, at.rows) => d.png,
_ => graphics::encode(&art.pixels, at.cells, at.rows)?,
};
graphics::place(out, at.x, at.y, at.cells.0, at.rows.1, &png)?;
last[i] = Some(Drawn { at, png });
drawn = true;
}
if drawn {
out.flush()?;
}
Ok(())
}
fn draw(frame: &mut ratatui::Frame, app: &App) -> Rect {
let area = frame.area();
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
app.path.display().to_string(),
Style::default()
.fg(TITLE_COLOR)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let outer = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(FRAME_COLOR))
.padding(Padding::vertical(1))
.title(title);
let inner = outer.inner(area);
frame.render_widget(outer, area);
let content_area = center_column(inner, app.content_width);
match app.mode {
Mode::Rendered => {
let paragraph = Paragraph::new(app.rendered.clone())
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0));
frame.render_widget(paragraph, content_area);
}
Mode::Raw => {
let paragraph = Paragraph::new(app.source.as_str())
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0));
frame.render_widget(paragraph, content_area);
}
}
render_scroll_thumb(
frame,
Rect {
x: area.right().saturating_sub(1),
y: content_area.y,
width: 1,
height: content_area.height,
},
app.line_count() as usize,
content_area.height as usize,
app.scroll as usize,
);
render_footer(frame, app, area);
if app.help {
render_help(frame, area);
}
content_area
}
fn footer_hint(app: &App) -> Line<'static> {
let key = Style::default()
.fg(FRAME_COLOR)
.add_modifier(Modifier::BOLD);
let hint = Style::default().fg(FRAME_COLOR);
if app.help {
return Line::from(vec![
Span::raw(" "),
Span::styled("esc/q/?", key),
Span::styled(" close ", hint),
]);
}
let mode_label = match app.mode {
Mode::Rendered => "rendered",
Mode::Raw => "raw",
};
let mut spans = vec![
Span::raw(" "),
Span::styled("tab", key),
Span::styled(format!(" {mode_label} "), hint),
Span::styled("j/k", key),
Span::styled(" scroll ", hint),
Span::styled("-/+", key),
Span::styled(" width ", hint),
Span::styled("?", key),
Span::styled(" keys ", hint),
Span::styled("q", key),
Span::styled(" quit ", hint),
];
if let Some(status) = app.current_status() {
let color = if status.error { Color::Red } else { TITLE_COLOR };
spans.push(Span::styled(
format!(" • {} ", status.text),
Style::default().fg(color),
));
} else if let Some(i) = app.hover {
spans.push(Span::styled(
format!(" • click: {} ", app.images[i].dest),
Style::default().fg(TITLE_COLOR),
));
}
Line::from(spans)
}
fn render_footer(frame: &mut ratatui::Frame, app: &App, area: Rect) {
if area.width < 4 || area.height < 1 {
return;
}
let row = Rect {
x: area.x + 1,
y: area.bottom() - 1,
width: area.width - 2,
height: 1,
};
let [hints, brand] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(BRAND.chars().count() as u16),
])
.areas(row);
frame.render_widget(Paragraph::new(footer_hint(app)), hints);
frame.render_widget(
Paragraph::new(BRAND).style(
Style::default()
.fg(BRAND_COLOR)
.add_modifier(Modifier::BOLD),
),
brand,
);
}
fn render_scroll_thumb(
frame: &mut ratatui::Frame,
area: Rect,
len: usize,
viewport: usize,
offset: usize,
) {
let track = area.height as usize;
if track == 0 || viewport == 0 || len <= viewport {
return;
}
let thumb = (track * viewport / len).clamp(1, track);
let travel = track - thumb;
let max_offset = len - viewport;
let start = (offset.min(max_offset) * travel + max_offset / 2) / max_offset;
let style = Style::default().fg(FRAME_COLOR);
let buf = frame.buffer_mut();
for i in start..(start + thumb).min(track) {
if let Some(cell) = buf.cell_mut((area.x, area.y + i as u16)) {
cell.set_symbol(SCROLL_THUMB).set_style(style);
}
}
}
fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (i, (title, items)) in sections.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
format!(" {title}"),
Style::default()
.fg(TITLE_COLOR)
.add_modifier(Modifier::BOLD),
)));
for (key, desc) in *items {
lines.push(Line::from(vec![
Span::styled(
format!(" {key:<12}"),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled((*desc).to_string(), Style::default().fg(FRAME_COLOR)),
]));
}
}
lines
}
fn render_help(frame: &mut ratatui::Frame, area: Rect) {
let left = help_lines(&[
(
"Scrolling",
&[
("j/k, ↑/↓", "scroll a line"),
("space, PgDn", "page down"),
("PgUp", "page up"),
("g, Home", "jump to top"),
("G, End", "jump to bottom"),
("wheel", "scroll"),
],
),
(
"View",
&[("tab", "rendered ↔ raw"), ("- / +", "column width")],
),
]);
let right = help_lines(&[
(
"Images",
&[("o", "open first below"), ("click", "open under cursor")],
),
(
"Other",
&[
("y", "copy file path"),
("?", "toggle this help"),
("q, esc", "quit"),
],
),
]);
let height = (left.len().max(right.len()) as u16 + 2).min(area.height);
let width = 68u16.min(area.width);
let dialog = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
frame.render_widget(Clear, dialog);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(TITLE_COLOR))
.title(Span::styled(
" keys ",
Style::default()
.fg(TITLE_COLOR)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(dialog);
frame.render_widget(block, dialog);
let [l, r] = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas(inner);
frame.render_widget(Paragraph::new(left), l);
frame.render_widget(Paragraph::new(right), r);
}
fn visual_line_count<'a>(text: impl Into<Text<'a>>, width: u16) -> u16 {
Paragraph::new(text)
.wrap(Wrap { trim: false })
.line_count(width.max(1))
.min(u16::MAX as usize) as u16
}
fn row_ranges(
text: &Text<'static>,
images: &[render::ImageRef],
width: u16,
) -> Vec<ImageRows> {
if images.is_empty() {
return vec![];
}
let mut offsets = Vec::with_capacity(text.lines.len() + 1);
let mut acc = 0usize;
offsets.push(0);
for line in &text.lines {
acc += visual_line_count(line.clone(), width) as usize;
offsets.push(acc);
}
let visual = |(a, b): (usize, usize)| {
let start = offsets.get(a).copied().unwrap_or(acc);
let end = offsets.get(b).copied().unwrap_or(acc).max(start + 1);
(start, end)
};
images
.iter()
.map(|img| ImageRows {
click: visual(img.lines),
art: img.art.as_ref().map(|a| visual(a.lines)),
})
.collect()
}
fn center_column(area: Rect, width: u16) -> Rect {
if area.width <= width {
return area;
}
let side = (area.width - width) / 2;
let [_, mid, _] = Layout::horizontal([
Constraint::Length(side),
Constraint::Length(width),
Constraint::Min(0),
])
.areas(area);
mid
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn fixture(name: &str, w: u32, h: u32) -> PathBuf {
let dir = env::temp_dir().join(format!("mdview-test-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let img = image::RgbImage::from_fn(w, h, |x, y| {
image::Rgb([(x % 251) as u8, (y % 253) as u8, 128])
});
img.save(dir.join("img.png")).unwrap();
dir
}
fn app_with(width: u16, md: &str, dir: &Path) -> App {
let (rendered, images) = render::render(md, width, dir);
let image_rows = row_ranges(&rendered, &images, width);
App {
path: dir.join("t.md"),
source: md.to_string(),
rendered,
images,
image_rows,
content_width: width,
mode: Mode::Rendered,
scroll: 0,
raw_line_count: 0,
rendered_line_count: u16::MAX,
status: None,
hover: None,
help: false,
}
}
const HEAD: &str = "\x1b]1337;File=inline=1;width=";
fn boxes(out: &[u8]) -> Vec<(u16, u16)> {
let s = String::from_utf8_lossy(out).into_owned();
s.match_indices(HEAD)
.map(|(i, _)| {
let rest = &s[i + HEAD.len()..];
let (w, rest) = rest.split_once(';').unwrap();
let h = rest
.strip_prefix("height=")
.unwrap()
.split(';')
.next()
.unwrap();
(w.parse().unwrap(), h.parse().unwrap())
})
.collect()
}
fn one_image(name: &str) -> (App, Rect) {
let dir = fixture(name, 800, 400);
let app = app_with(76, "lead in\n\n\n", &dir);
(app, Rect::new(2, 1, 76, 40))
}
#[test]
fn the_box_matches_the_rows_the_halfblocks_reserved() {
let (app, area) = one_image("box");
let art = app.images[0].art.as_ref().unwrap();
let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut vec![]).unwrap();
assert_eq!(boxes(&out), vec![art.cells]);
assert_eq!(art.cells, (60, 15));
}
#[test]
fn a_partly_scrolled_image_crops_to_its_visible_rows() {
let (mut app, area) = one_image("crop");
let (start, end) = app.image_rows[0].art.unwrap();
let rows = (end - start) as u16;
app.scroll = start as u16 + 4;
let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut vec![]).unwrap();
assert_eq!(boxes(&out), vec![(60, rows - 4)]);
app.scroll = 0;
let short = Rect::new(2, 1, 76, start as u16 + 3);
let mut out = vec![];
draw_inline_images(&mut out, &app, short, &mut vec![]).unwrap();
assert_eq!(boxes(&out), vec![(60, 3)]);
}
#[test]
fn an_image_scrolled_out_of_view_is_not_drawn() {
let (mut app, area) = one_image("gone");
let (_, end) = app.image_rows[0].art.unwrap();
app.scroll = end as u16;
let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut vec![]).unwrap();
assert!(boxes(&out).is_empty());
}
#[test]
fn raw_view_and_the_help_overlay_draw_nothing() {
for set in [
(|a: &mut App| a.mode = Mode::Raw) as fn(&mut App),
|a: &mut App| a.help = true,
] {
let (mut app, area) = one_image("suppressed");
set(&mut app);
let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut vec![]).unwrap();
assert!(boxes(&out).is_empty());
}
}
#[test]
fn coming_back_from_raw_view_redraws_even_though_nothing_moved() {
let (mut app, area) = one_image("return");
let mut placed = vec![];
let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut placed).unwrap();
assert_eq!(boxes(&out).len(), 1);
app.mode = Mode::Raw;
draw_inline_images(&mut vec![], &app, area, &mut placed).unwrap();
app.mode = Mode::Rendered;
let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut placed).unwrap();
assert_eq!(boxes(&out).len(), 1, "raw view overwrote it; it must be resent");
}
#[test]
fn an_unmoved_image_is_not_resent_but_a_moved_one_is() {
let (mut app, area) = one_image("resend");
let mut placed = vec![];
let mut first = vec![];
draw_inline_images(&mut first, &app, area, &mut placed).unwrap();
assert_eq!(boxes(&first).len(), 1);
let mut again = vec![];
draw_inline_images(&mut again, &app, area, &mut placed).unwrap();
assert!(again.is_empty());
app.scroll += 1;
let mut moved = vec![];
draw_inline_images(&mut moved, &app, area, &mut placed).unwrap();
assert_eq!(boxes(&moved).len(), 1);
}
#[test]
fn a_resize_forces_a_redraw_of_images_that_did_not_move() {
let (app, area) = one_image("resize");
let mut placed = vec![];
draw_inline_images(&mut vec![], &app, area, &mut placed).unwrap();
placed.clear(); let mut out = vec![];
draw_inline_images(&mut out, &app, area, &mut placed).unwrap();
assert_eq!(boxes(&out).len(), 1);
}
#[test]
fn an_image_smaller_than_the_column_keeps_its_own_size() {
let dir = fixture("small", 160, 160);
let app = app_with(76, "lead in\n\n\n", &dir);
let art = app.images[0].art.as_ref().unwrap();
assert_eq!(art.cells, (20, 10));
assert_eq!(
art.pixels.width(),
160,
"a small image must not be upscaled on the way out"
);
}
}