use image::{Rgba, RgbaImage};
use super::backend::{ProofBackend, RenderContext};
use super::report::{ProofCapture, ProofError, ProofReport};
use crate::frame::TerminalFrame;
use crate::renderer;
const LABEL_HEIGHT: u32 = 32;
const LABEL_BG: Rgba<u8> = Rgba([50, 50, 50, 255]);
const LABEL_FG: Rgba<u8> = Rgba([220, 220, 220, 255]);
const FRAME_PADDING: u32 = 4;
pub struct ScreenshotStripBackend;
impl ProofBackend for ScreenshotStripBackend {
fn render(&self, context: &RenderContext<'_>) -> Result<(), ProofError> {
let report = context.report;
let rendered_frames = render_all_frames(report);
let strip = compose_strip(&rendered_frames, report);
strip
.save(context.output)
.map_err(|err| ProofError::Format(err.to_string()))?;
Ok(())
}
}
fn render_all_frames(report: &ProofReport) -> Vec<(RgbaImage, &ProofCapture)> {
report
.captures
.iter()
.map(|capture| {
let frame = TerminalFrame::new(capture.cols, capture.rows, &capture.frame_bytes);
let image = renderer::render_to_image(&frame);
(image, capture)
})
.collect()
}
fn compose_strip(frames: &[(RgbaImage, &ProofCapture)], report: &ProofReport) -> RgbaImage {
if frames.is_empty() {
return RgbaImage::new(1, 1);
}
let max_width = frames
.iter()
.map(|(image, _)| image.width())
.max()
.unwrap_or(1);
let total_height: u32 = frames
.iter()
.enumerate()
.map(|(index, (image, _))| {
let label_h = LABEL_HEIGHT;
let padding = if index + 1 < frames.len() {
FRAME_PADDING
} else {
0
};
label_h + image.height() + padding
})
.sum();
let mut strip = RgbaImage::from_pixel(max_width, total_height, Rgba([30, 30, 30, 255]));
let mut current_y = 0u32;
for (index, (frame_image, capture)) in frames.iter().enumerate() {
draw_label_header(
&mut strip,
current_y,
max_width,
index + 1,
&capture.label,
&capture.description,
);
current_y += LABEL_HEIGHT;
copy_image_onto(&mut strip, frame_image, 0, current_y);
current_y += frame_image.height();
if index + 1 < report.captures.len() {
current_y += FRAME_PADDING;
}
}
strip
}
fn draw_label_header(
image: &mut RgbaImage,
start_y: u32,
width: u32,
step_number: usize,
label: &str,
description: &str,
) {
for pixel_y in start_y..start_y + LABEL_HEIGHT {
for pixel_x in 0..width {
if pixel_x < image.width() && pixel_y < image.height() {
image.put_pixel(pixel_x, pixel_y, LABEL_BG);
}
}
}
let text = format!("Step {step_number}: [{label}] {description}");
draw_text_line(image, 4, start_y + 8, &text, LABEL_FG);
}
fn draw_text_line(image: &mut RgbaImage, start_x: u32, start_y: u32, text: &str, color: Rgba<u8>) {
let mut cursor_x = start_x;
for character in text.chars() {
if cursor_x + 8 > image.width() {
break;
}
draw_bitmap_char(image, cursor_x, start_y, character, color);
cursor_x += 8;
}
}
fn draw_bitmap_char(
image: &mut RgbaImage,
pixel_x: u32,
pixel_y: u32,
character: char,
color: Rgba<u8>,
) {
let code = character as u32;
if !(0x20..=0x7E).contains(&code) {
return;
}
let glyph_index = (code - 0x20) as usize;
let glyph = &renderer::ASCII_GLYPHS[glyph_index];
for (glyph_row, &byte) in glyph.iter().enumerate() {
let row_offset = u32::try_from(glyph_row).unwrap_or(0);
for bit in 0..8u32 {
if byte & (0x80 >> bit) != 0 {
let target_x = pixel_x + bit;
let target_y = pixel_y + row_offset;
if target_x < image.width() && target_y < image.height() {
image.put_pixel(target_x, target_y, color);
}
}
}
}
}
fn copy_image_onto(destination: &mut RgbaImage, source: &RgbaImage, offset_x: u32, offset_y: u32) {
for source_y in 0..source.height() {
for source_x in 0..source.width() {
let target_x = offset_x + source_x;
let target_y = offset_y + source_y;
if target_x < destination.width() && target_y < destination.height() {
destination.put_pixel(target_x, target_y, *source.get_pixel(source_x, source_y));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_of_two_captures_is_taller_than_single_frame() {
let frame_a = TerminalFrame::new(40, 5, b"First");
let frame_b = TerminalFrame::new(40, 5, b"Second");
let mut report = ProofReport::new("strip_test");
report.add_capture("first", "First capture", &frame_a);
report.add_capture("second", "Second capture", &frame_b);
let rendered_frames = render_all_frames(&report);
let strip = compose_strip(&rendered_frames, &report);
let single_frame_height = 5 * 16;
assert!(strip.height() > single_frame_height);
}
#[test]
fn strip_backend_writes_valid_png() {
let frame = TerminalFrame::new(20, 3, b"Hello strip");
let mut report = ProofReport::new("png_test");
report.add_capture("snap", "Snapshot", &frame);
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let output_path = temp_dir.path().join("strip.png");
let backend = ScreenshotStripBackend;
backend
.render(&RenderContext::new(&report, &output_path))
.expect("render should succeed");
assert!(output_path.exists());
let loaded = image::open(&output_path).expect("should open as image");
assert!(loaded.width() > 0);
assert!(loaded.height() > 0);
}
#[test]
fn empty_report_produces_minimal_strip() {
let report = ProofReport::new("empty");
let rendered_frames = render_all_frames(&report);
let strip = compose_strip(&rendered_frames, &report);
assert_eq!(strip.width(), 1);
assert_eq!(strip.height(), 1);
}
#[test]
fn strip_width_matches_widest_frame() {
let narrow = TerminalFrame::new(20, 3, b"Narrow");
let wide = TerminalFrame::new(40, 3, b"Wide");
let mut report = ProofReport::new("width_test");
report.add_capture("narrow", "Narrow frame", &narrow);
report.add_capture("wide", "Wide frame", &wide);
let rendered_frames = render_all_frames(&report);
let strip = compose_strip(&rendered_frames, &report);
assert_eq!(strip.width(), 320);
}
}