diff --git a/Cargo.lock b/Cargo.lock index e45568db..3261c9ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2033,6 +2033,7 @@ dependencies = [ "dialoguer", "dirs", "interprocess", + "png", "serde", "serde_json", "sha2", diff --git a/README.md b/README.md index d2cdc8d7..76622d64 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ Timeout defaults: | Command | Description | | --- | --- | -| `screenshot [PATH] [-o PATH] [--full] [--zoom N] [--background COLOR \| --transparent]` | Print text or save SVG. | +| `screenshot [PATH] [-o PATH] [--full] [--zoom N] [--background COLOR \| --transparent]` | Print text or save SVG or PNG. | | `record start PATH [options] [--background COLOR \| --transparent]` | Start APNG, GIF, MP4, or asciinema recording. | | `record stop` | Finish the recording. | | `get-recording [SESSION] [--config PATH]` | Print the automatic asciinema recording. | diff --git a/SKILL.md b/SKILL.md index 453c3283..f801b382 100644 --- a/SKILL.md +++ b/SKILL.md @@ -105,7 +105,7 @@ Do not use a fixed sleep when a wait can describe the state. ## Capture output -Use `screenshot` for text or SVG. SVG, APNG, and GIF support `--background COLOR` and `--transparent`. MP4 supports `--background` only. Use `record` for APNG, GIF, MP4, or asciinema. Use recording mode `on-failure` for test artifacts. +Use `screenshot` for text, SVG, or PNG. SVG, PNG, APNG, and GIF support `--background COLOR` and `--transparent`. MP4 supports `--background` only. Use `record` for APNG, GIF, MP4, or asciinema. Use recording mode `on-failure` for test artifacts. ## References diff --git a/bindings/js/README.md b/bindings/js/README.md index 80d71c70..eb9b89ad 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -125,7 +125,7 @@ Snapshot options are `update`, `includeStyle`, and `includeTitle`. | Method | Description | | --- | --- | -| `screenshot(path?, { full?, zoom?, background?, transparent? })` | Return text or save SVG. | +| `screenshot(path?, { full?, zoom?, background?, transparent? })` | Return text or save SVG or PNG. | | `startRecording(path, options?)` | Start APNG, GIF, MP4, or asciinema recording. | | `stopRecording()` | Finish the recording and return its path. | diff --git a/bindings/python/README.md b/bindings/python/README.md index 6304f62e..54cca6d1 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -120,7 +120,7 @@ Snapshot options are `update`, `include_style`, and `include_title`. | Method | Description | | --- | --- | -| `await screenshot(path=None, full=False, zoom=None, background=None, transparent=False)` | Return text or save SVG. | +| `await screenshot(path=None, full=False, zoom=None, background=None, transparent=False)` | Return text or save SVG or PNG. | | `await start_recording(path, **options)` | Start APNG, GIF, MP4, or asciinema recording. | | `await stop_recording()` | Finish the recording and return its path. | diff --git a/crates/tui-test-cli/Cargo.toml b/crates/tui-test-cli/Cargo.toml index 64ed327e..288e2bc4 100644 --- a/crates/tui-test-cli/Cargo.toml +++ b/crates/tui-test-cli/Cargo.toml @@ -32,3 +32,6 @@ tui-test = { workspace = true, features = [ [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Win32_System_Console"] } + +[dev-dependencies] +png.workspace = true diff --git a/crates/tui-test-cli/src/cli.rs b/crates/tui-test-cli/src/cli.rs index 2b463c79..37d6467c 100644 --- a/crates/tui-test-cli/src/cli.rs +++ b/crates/tui-test-cli/src/cli.rs @@ -151,21 +151,21 @@ pub struct Cli { #[derive(Args)] pub struct ScreenshotArgs { - /// Write an SVG image to this path (alias for --out). + /// Write an SVG or PNG image to this path (alias for --out). pub path: Option, - /// Write an SVG image to this path. + /// Write an SVG or PNG image to this path. #[arg(short, long)] pub out: Option, /// Include scrollback, not just the visible viewport. #[arg(long)] pub full: bool, - /// Scale the SVG dimensions while keeping the same terminal cells. + /// Scale image dimensions while keeping the same terminal cells. #[arg(long)] pub zoom: Option, /// Canvas background color (#rgb or #rrggbb). #[arg(long, conflicts_with = "transparent")] pub background: Option, - /// Leave the SVG canvas transparent. + /// Leave the image canvas transparent. #[arg(long)] pub transparent: bool, } @@ -271,8 +271,8 @@ pub enum Command { #[arg(long)] full: bool, }, - /// Capture a screenshot: terminal text to stdout, or a full-color SVG image - /// when an output path is given (crisp at any zoom). + /// Capture a screenshot: terminal text to stdout, or a full-color SVG/PNG + /// image selected by the output extension (SVG is the default). Screenshot(ScreenshotArgs), /// Start or stop an animated terminal recording. Record { diff --git a/crates/tui-test-cli/src/main.rs b/crates/tui-test-cli/src/main.rs index ccec8896..66dd28f8 100644 --- a/crates/tui-test-cli/src/main.rs +++ b/crates/tui-test-cli/src/main.rs @@ -1327,7 +1327,7 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ [global options] -- [args...] (direct run shorthand)\n\ restart [--graceful-timeout MS]\n\ sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\ -INSPECT state | text [--full] | screenshot [-o file.svg] [--full] [--zoom N]\n\ +INSPECT state | text [--full] | screenshot [-o file.svg|file.png] [--full] [--zoom N]\n\ [--background COLOR | --transparent]\n\ find text \"T\" [selector/style options] | cells X Y [W H]\n\ get command|output|exit-code|cwd|cursor|size|title|clipboard|bells|bell-events\n\ diff --git a/crates/tui-test-cli/tests/session_lifecycle.rs b/crates/tui-test-cli/tests/session_lifecycle.rs index 8ffe6073..c48f2570 100644 --- a/crates/tui-test-cli/tests/session_lifecycle.rs +++ b/crates/tui-test-cli/tests/session_lifecycle.rs @@ -59,6 +59,10 @@ const CALL_TIMEOUT: Duration = Duration::from_secs(60); static SANDBOX_SEQ: AtomicU32 = AtomicU32::new(0); +fn contains_rgba(pixels: &[u8], expected: [u8; 4]) -> bool { + pixels.chunks(4).any(|pixel| pixel == expected) +} + struct Sandbox { label: &'static str, home: PathBuf, @@ -320,6 +324,63 @@ fn monitor_windows_console_forwards_ctrl_z_and_unicode_without_detaching() { target.ok(&["daemon", "status"]); } +#[test] +fn screenshots_dispatch_by_extension_without_changing_svg_output() { + let sandbox = Sandbox::new("screenshot-formats"); + let program = r#"printf "\033[41m \033[0m\033[38;2;0;255;0mX\033[0m\033]12;#ff00ff\007\033[1;4H"; sleep 30"#; + sandbox.ok(&[ + "run", "--cols", "4", "--rows", "3", "--", "bash", "--norc", "-c", program, + ]); + sandbox.wait_for_text("X", "5000"); + + let svg = sandbox.home.join("screen.svg"); + let extensionless = sandbox.home.join("screen"); + sandbox.ok(&["screenshot", svg.to_str().unwrap()]); + sandbox.ok(&["screenshot", extensionless.to_str().unwrap()]); + let svg_bytes = std::fs::read(&svg).unwrap(); + assert!(svg_bytes.starts_with(b" SvgSnapshot { snapshot } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScreenshotFormat { + Svg, + Png, +} + +impl ScreenshotFormat { + fn infer(path: &str) -> Result { + let extension = std::path::Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase); + match extension.as_deref() { + None | Some("svg") => Ok(Self::Svg), + Some("png") => Ok(Self::Png), + Some(extension) => Err(TuiTestError::usage(format!( + "unsupported screenshot extension '.{extension}'; use .svg or .png" + ))), + } + } +} + fn screenshot( session: &TerminalSession, full: bool, @@ -2665,18 +2687,55 @@ fn screenshot( match path { Some(path) => { let zoom = crate::api::resolve_zoom(zoom)?; + let format = ScreenshotFormat::infer(&path)?; let snapshot = svg_snapshot(session, full); - let svg = crate::render::svg::render_svg_with_zoom( - &snapshot.rows, - snapshot.cols, - &snapshot.render_state, - snapshot.cursor, - snapshot.title.as_deref(), - zoom, - background, - ); - std::fs::write(&path, svg) - .map_err(|error| TuiTestError::internal(error.to_string()))?; + match format { + ScreenshotFormat::Svg => { + let svg = crate::render::svg::render_svg_with_zoom( + &snapshot.rows, + snapshot.cols, + &snapshot.render_state, + snapshot.cursor, + snapshot.title.as_deref(), + zoom, + background, + ); + std::fs::write(&path, svg) + .map_err(|error| TuiTestError::internal(error.to_string()))?; + } + ScreenshotFormat::Png => { + #[cfg(feature = "recording-raster")] + { + let rows = snapshot.rows.len(); + let frame = crate::record::frames::Frame { + grid: snapshot.rows, + title: snapshot.title, + duration: Duration::ZERO, + render_state: snapshot.render_state, + cursor: snapshot.cursor, + }; + let mut renderer = crate::render::raster::GridRenderer::for_screenshot( + snapshot.cols, + rows, + zoom, + background, + ) + .map_err(|error| TuiTestError::internal(error.to_string()))?; + crate::render::encode::encode_png( + std::path::Path::new(&path), + &frame, + &mut renderer, + ) + .map_err(|error| TuiTestError::internal(error.to_string()))?; + } + #[cfg(not(feature = "recording-raster"))] + { + return Err(TuiTestError::usage( + "PNG screenshots require the tui-test 'recording-raster' feature", + )); + } + } + } Ok(ScreenshotResult::Path(path)) } None if zoom.is_some() || background.is_some() => Err(TuiTestError::usage( @@ -2908,6 +2967,27 @@ mod tests { assert!(format!("{error:?}").contains("no meaning"), "{error:?}"); } + #[test] + fn screenshot_format_defaults_to_svg_and_rejects_unknown_extensions() { + assert_eq!( + ScreenshotFormat::infer("screen").unwrap(), + ScreenshotFormat::Svg + ); + assert_eq!( + ScreenshotFormat::infer("screen.SVG").unwrap(), + ScreenshotFormat::Svg + ); + assert_eq!( + ScreenshotFormat::infer("screen.PNG").unwrap(), + ScreenshotFormat::Png + ); + let error = ScreenshotFormat::infer("screen.gif").unwrap_err(); + assert_eq!(error.kind, ErrorKind::Usage); + assert!(error.message.contains(".gif")); + assert!(error.message.contains(".svg")); + assert!(error.message.contains(".png")); + } + #[test] fn cell_model_reports_the_whole_vocabulary() { let cell = EmuCell { diff --git a/crates/tui-test/src/render/encode.rs b/crates/tui-test/src/render/encode.rs index 67d5a9c8..8f96e097 100644 --- a/crates/tui-test/src/render/encode.rs +++ b/crates/tui-test/src/render/encode.rs @@ -38,6 +38,24 @@ pub(crate) fn encode( } } +pub(crate) fn encode_png( + path: &Path, + frame: &Frame, + renderer: &mut dyn FrameRenderer, +) -> anyhow::Result<()> { + let (width, height) = renderer.pixel_size(); + let output = BufWriter::new(File::create(path)?); + let mut encoder = png::Encoder::new(output, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive); + let mut writer = encoder.write_header()?; + let image = renderer.render(frame)?; + writer.write_image_data(image.as_raw())?; + writer.finish()?; + Ok(()) +} + fn encode_mp4( path: &Path, frames: &[Frame], @@ -480,6 +498,27 @@ mod tests { } } + #[test] + fn png_is_static_and_round_trips_dimensions_and_color() { + let path = temp_path("png"); + let frame = frame(Color::Rgb(200, 10, 20), Duration::ZERO); + let mut renderer = GridRenderer::with_zoom(1, 1, 1.5).unwrap(); + encode_png(&path, &frame, &mut renderer).unwrap(); + + let bytes = std::fs::read(&path).unwrap(); + let chunks = png_chunks(&bytes); + assert_eq!(png_dimensions(&chunks), renderer.pixel_size()); + assert!(!chunks.iter().any(|(kind, _)| kind == b"acTL")); + let pixel = decode_first_png_pixel( + &path, + ((crate::render::raster::CANVAS_PADDING + 20) as f64 * 1.5) as u32, + ((crate::render::raster::CANVAS_PADDING + 48) as f64 * 1.5) as u32, + ); + assert_eq!(&pixel[..3], &[200, 10, 20]); + + std::fs::remove_file(path).unwrap(); + } + #[test] fn repeated_apng_encodes_are_byte_identical() { let first = temp_path("png"); diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index 422b3f37..63fb9a39 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -52,6 +52,7 @@ pub trait FrameRenderer { pub struct GridRenderer { max_cols: u16, max_rows: usize, + exact_size: bool, scale: f32, width: u32, height: u32, @@ -71,7 +72,7 @@ impl GridRenderer { } pub fn with_zoom(cols: u16, rows: usize, zoom: f64) -> anyhow::Result { - Self::with_zoom_and_background(cols, rows, zoom, None) + Self::with_zoom_background_and_size(cols, rows, zoom, None, false) } pub fn with_zoom_and_background( @@ -79,11 +80,34 @@ impl GridRenderer { rows: usize, zoom: f64, background: Option, + ) -> anyhow::Result { + Self::with_zoom_background_and_size(cols, rows, zoom, background, false) + } + + pub(crate) fn for_screenshot( + cols: u16, + rows: usize, + zoom: f64, + background: Option, + ) -> anyhow::Result { + Self::with_zoom_background_and_size(cols, rows, zoom, background, true) + } + + fn with_zoom_background_and_size( + cols: u16, + rows: usize, + zoom: f64, + background: Option, + exact_size: bool, ) -> anyhow::Result { if !zoom.is_finite() || zoom <= 0.0 || zoom > f64::from(f32::MAX) { anyhow::bail!("recording zoom must be finite and greater than zero"); } - let (base_width, base_height) = svg::pixel_size(cols, rows); + let (base_width, base_height) = if exact_size { + svg::exact_pixel_size(cols, rows) + } else { + svg::pixel_size(cols, rows) + }; let padding = CANVAS_PADDING .checked_mul(2) .expect("recording canvas padding must fit in u32"); @@ -98,6 +122,7 @@ impl GridRenderer { Ok(Self { max_cols: cols, max_rows: rows, + exact_size, scale: zoom as f32, width, height, @@ -127,7 +152,11 @@ impl FrameRenderer for GridRenderer { let scale = self.scale; let colors = &frame.render_state; - let (base_width, base_height) = svg::pixel_size(cols, rows); + let (base_width, base_height) = if self.exact_size { + svg::exact_pixel_size(cols, rows) + } else { + svg::pixel_size(cols, rows) + }; let panel_width = scaled_dimension(base_width, f64::from(self.scale), "frame width")?; let panel_height = scaled_dimension(base_height, f64::from(self.scale), "frame height")?; let origin_x = (self.width - panel_width) as f32 / 2.0; diff --git a/crates/tui-test/src/render/raster/tests.rs b/crates/tui-test/src/render/raster/tests.rs index 29012e15..512c8238 100644 --- a/crates/tui-test/src/render/raster/tests.rs +++ b/crates/tui-test/src/render/raster/tests.rs @@ -58,6 +58,19 @@ fn scaled_renderers_multiply_output_dimensions() { ); } +#[test] +fn static_screenshots_keep_odd_svg_dimensions_at_zoom() { + for (zoom, recording_size, screenshot_size) in + [(1.0, (88, 122), (88, 121)), (2.0, (176, 244), (176, 242))] + { + let recording = GridRenderer::with_zoom(1, 1, zoom).unwrap(); + let screenshot = GridRenderer::for_screenshot(1, 1, zoom, None).unwrap(); + + assert_eq!(recording.pixel_size(), recording_size); + assert_eq!(screenshot.pixel_size(), screenshot_size); + } +} + #[test] fn fractional_zoom_shrinks_output_without_changing_grid_dimensions() { let standard = GridRenderer::new(80, 30); diff --git a/crates/tui-test/src/render/svg.rs b/crates/tui-test/src/render/svg.rs index 7af5aeff..5f144661 100644 --- a/crates/tui-test/src/render/svg.rs +++ b/crates/tui-test/src/render/svg.rs @@ -557,10 +557,16 @@ pub(crate) fn render_svg_with_zoom( #[cfg(feature = "recording-raster")] pub(crate) fn pixel_size(cols: u16, rows: usize) -> (u32, u32) { + let (width, height) = exact_pixel_size(cols, rows); + (width + width % 2, height + height % 2) +} + +#[cfg(feature = "recording-raster")] +pub(crate) fn exact_pixel_size(cols: u16, rows: usize) -> (u32, u32) { let width = (MARGIN_X * 2.0 + f32::from(cols) * CELL_W).ceil() as u32; let height = (HEADER_H + CONTENT_PADDING_TOP + MARGIN_BOTTOM + rows.max(1) as f32 * CELL_H) .ceil() as u32; - (width + width % 2, height + height % 2) + (width, height) } #[cfg(test)] diff --git a/references/cli.md b/references/cli.md index c6875abe..7b84fc16 100644 --- a/references/cli.md +++ b/references/cli.md @@ -88,7 +88,7 @@ Most waits accept `--timeout MS`. `expect`, `click`, and `highlight` retry. `fin | `text [--full]` | Read terminal text. | | `cells X Y [W H]` | Read cells and styles. | | `get FIELD` | Read one field. | -| `screenshot [PATH] [--background COLOR \| --transparent]` | Read text or save SVG. | +| `screenshot [PATH] [--background COLOR \| --transparent]` | Read text or save SVG or PNG. | Fields: `command`, `output`, `exit-code`, `cwd`, `cursor`, `size`, `title`, `clipboard`, `bells`, and `bell-events`. diff --git a/references/javascript.md b/references/javascript.md index 6840150e..78acdb1a 100644 --- a/references/javascript.md +++ b/references/javascript.md @@ -69,7 +69,7 @@ Click options: `button`, `alt`, `ctrl`, `shift`, `clicks`, and `timeout`. | `waitTitle()`, `waitClipboard()`, `waitBell()` | Wait for events. | | `expectTitle()`, `expectOutput()`, `expectExitCode()` | Assert state. | | `expectBellCount()`, `expectSnapshot()` | Assert bells or snapshots. | -| `screenshot()` | Read text or save SVG. | +| `screenshot()` | Read text or save SVG or PNG. | | `startRecording()`, `stopRecording()` | Record. | | `close()`, `closeQuiet()` | Close. | diff --git a/references/python.md b/references/python.md index 0f9aa95d..5febd01a 100644 --- a/references/python.md +++ b/references/python.md @@ -67,7 +67,7 @@ Click options: `button`, `alt`, `ctrl`, `shift`, `clicks`, and `timeout`. | `wait_title()`, `wait_clipboard()`, `wait_bell()` | Wait for events. | | `expect_title()`, `expect_output()`, `expect_exit_code()` | Assert state. | | `expect_bell_count()`, `expect_snapshot()` | Assert bells or snapshots. | -| `screenshot()` | Read text or save SVG. | +| `screenshot()` | Read text or save SVG or PNG. | | `start_recording()`, `stop_recording()` | Record. | | `close()`, `close_quiet()` | Close. |