From 6294b75eb1ffa18a624552121a9a8698e5908868 Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 13 Aug 2026 20:57:56 -0700 Subject: [PATCH 1/5] feat: add image and video zoom --- bindings/js/native/index.d.ts | 2 + bindings/js/native/lib.rs | 5 ++ bindings/js/src/client.ts | 10 +++- bindings/js/src/index.ts | 1 + bindings/js/test/integration.test.mjs | 14 ++++- bindings/python/native/src/lib.rs | 10 +++- bindings/python/src/tui_test/_native.pyi | 4 +- bindings/python/src/tui_test/client.py | 11 +++- bindings/python/stub-gen/src/main.rs | 8 ++- bindings/python/tests/test_integration.py | 24 +++++++- bindings/python/tests/test_options.py | 15 ++++- crates/tui-test-cli/src/cli.rs | 29 ++++++++++ crates/tui-test-cli/src/main.rs | 14 ++++- crates/tui-test-cli/src/protocol.rs | 28 +++++++++- .../tui-test-cli/tests/session_lifecycle.rs | 12 +++- crates/tui-test/src/api.rs | 24 ++++++++ crates/tui-test/src/engine.rs | 12 ++-- crates/tui-test/src/record.rs | 2 + crates/tui-test/src/record/worker.rs | 1 + crates/tui-test/src/render/raster.rs | 55 +++++++++++-------- crates/tui-test/src/render/raster/tests.rs | 24 ++++++++ crates/tui-test/src/render/svg.rs | 37 ++++++++++++- crates/tui-test/src/session.rs | 11 +++- crates/tui-test/tests/runtime.rs | 5 +- 24 files changed, 307 insertions(+), 51 deletions(-) diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 0e36f7f1..5e9307be 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -158,6 +158,7 @@ export interface RecordingOptions { fps?: number speed?: number idleTimeLimit?: number + zoom?: number } export interface RunOptions { @@ -177,6 +178,7 @@ export interface RunOptions { export interface ScreenshotOptions { full?: boolean path?: string + zoom?: number } export declare function sessions(): Promise> diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index d600766a..14f9ace9 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -376,6 +376,7 @@ pub struct SnapshotOptions { pub struct ScreenshotOptions { pub full: Option, pub path: Option, + pub zoom: Option, } #[napi(object)] @@ -385,6 +386,7 @@ pub struct RecordingOptions { pub fps: Option, pub speed: Option, pub idle_time_limit: Option, + pub zoom: Option, } #[napi(string_enum = "lowercase")] @@ -1180,6 +1182,7 @@ impl NativeSession { let options = options.unwrap_or(ScreenshotOptions { full: None, path: None, + zoom: None, }); execute( self.handle.clone(), @@ -1187,6 +1190,7 @@ impl NativeSession { Operation::Screenshot { full: options.full.unwrap_or(false), path: options.path, + zoom: options.zoom, }, |result| match result { OperationResult::Screenshot(CoreScreenshotResult::Path(value)) @@ -1212,6 +1216,7 @@ impl NativeSession { fps, speed: options.speed, idle_time_limit: options.idle_time_limit, + zoom: options.zoom, }, ) .await diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index 129f75bd..2ce399cf 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -61,6 +61,12 @@ export interface RecordingOptions { fps?: number; speed?: number; idleTimeLimit?: number; + zoom?: number; +} + +export interface ScreenshotOptions { + full?: boolean; + zoom?: number; } const TERMINAL_MARKER = "Terminal content:\n"; @@ -344,10 +350,11 @@ export class TuiTest { return this.#runtime.getSize(); } - async screenshot(path: string | null = null, opts: { full?: boolean } = {}): Promise { + async screenshot(path: string | null = null, opts: ScreenshotOptions = {}): Promise { return this.#runtime.screenshot({ full: opts.full ?? false, path: optional(path), + zoom: opts.zoom, }); } @@ -358,6 +365,7 @@ export class TuiTest { fps: opts.fps, speed: opts.speed, idleTimeLimit: opts.idleTimeLimit, + zoom: opts.zoom, }); } diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index 99fd123b..a929e70c 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -5,6 +5,7 @@ export type { TitleOptions, RecordingFormat, RecordingOptions, + ScreenshotOptions, WaitTextOptions, } from "./client.js"; export { uniqueSession } from "./ephemeral.js"; diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 34aaca23..0f462f48 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -99,7 +99,15 @@ test("recording API exports styled Unicode to APNG and GIF", async () => { ]) { const path = join(root, `styled.${extension}`); await withTerminal({ shell, cols: 20, rows: 4 }, async (su) => { - await su.startRecording(path, { format, fps: 30 }); + if (format === "apng") { + const screenshotPath = join(root, "zoomed.svg"); + await su.screenshot(screenshotPath, { zoom: 0.5 }); + assert.match( + await readFile(screenshotPath, "utf8"), + /width="115" height="68" viewBox="0 0 230 136"/, + ); + } + await su.startRecording(path, { format, fps: 30, zoom: 0.5 }); await su.submit(command); await su.waitCommand(); assert.equal(await su.stopRecording(), path); @@ -108,8 +116,12 @@ test("recording API exports styled Unicode to APNG and GIF", async () => { if (format === "apng") { assert.deepEqual(bytes.subarray(0, 8), Buffer.from("\x89PNG\r\n\x1a\n", "latin1")); assert.ok(bytes.includes(Buffer.from("acTL"))); + assert.equal(bytes.readUInt32BE(16), 278); + assert.equal(bytes.readUInt32BE(20), 184); } else { assert.equal(bytes.subarray(0, 6).toString("ascii"), "GIF89a"); + assert.equal(bytes.readUInt16LE(6), 278); + assert.equal(bytes.readUInt16LE(8), 184); } } } finally { diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs index bc1016c0..8a0a5971 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -881,22 +881,24 @@ impl NativeSession { ) } - #[pyo3(signature = (path, full))] + #[pyo3(signature = (path, full, zoom=None))] fn screenshot<'py>( &self, py: Python<'py>, path: Option, full: bool, + zoom: Option, ) -> PyResult> { let name = self.name.clone(); future_blocking( py, - move || execute_screenshot(&name, Operation::Screenshot { full, path }), + move || execute_screenshot(&name, Operation::Screenshot { full, path, zoom }), screenshot_to_py, ) } - #[pyo3(signature = (path, format, fps, speed, idle_time_limit))] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (path, format, fps, speed, idle_time_limit, zoom=None))] fn start_recording<'py>( &self, py: Python<'py>, @@ -905,6 +907,7 @@ impl NativeSession { fps: Option>, speed: Option, idle_time_limit: Option, + zoom: Option, ) -> PyResult> { let fps = capture_optional_integer(fps); let name = self.name.clone(); @@ -922,6 +925,7 @@ impl NativeSession { .transpose()?, speed, idle_time_limit, + zoom, }, ) }, diff --git a/bindings/python/src/tui_test/_native.pyi b/bindings/python/src/tui_test/_native.pyi index 256ca0f3..1b6cc2b0 100644 --- a/bindings/python/src/tui_test/_native.pyi +++ b/bindings/python/src/tui_test/_native.pyi @@ -80,8 +80,8 @@ class NativeSession: def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... def snapshot(self, name: str, update: bool, include_colors: bool, include_title: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ... - def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... - def start_recording(self, path: str, format: typing.Optional[str], fps: typing.Optional[int], speed: typing.Optional[float], idle_time_limit: typing.Optional[float]) -> typing.Awaitable[None]: ... + def screenshot(self, path: typing.Optional[str], full: bool, zoom: typing.Optional[float]) -> typing.Awaitable[str]: ... + def start_recording(self, path: str, format: typing.Optional[str], fps: typing.Optional[int], speed: typing.Optional[float], idle_time_limit: typing.Optional[float], zoom: typing.Optional[float]) -> typing.Awaitable[None]: ... def stop_recording(self) -> typing.Awaitable[str]: ... def recording(self) -> typing.Awaitable[str]: ... diff --git a/bindings/python/src/tui_test/client.py b/bindings/python/src/tui_test/client.py index 7392389d..f55d580b 100644 --- a/bindings/python/src/tui_test/client.py +++ b/bindings/python/src/tui_test/client.py @@ -368,9 +368,13 @@ async def get_size(self) -> Dict[str, int]: return await self._await(self._native.get_size()) async def screenshot( - self, path: Optional[str] = None, *, full: bool = False + self, + path: Optional[str] = None, + *, + full: bool = False, + zoom: Optional[float] = None, ) -> str: - return await self._await(self._native.screenshot(path, full)) + return await self._await(self._native.screenshot(path, full, zoom)) async def start_recording( self, @@ -380,10 +384,11 @@ async def start_recording( fps: Optional[int] = None, speed: Optional[float] = None, idle_time_limit: Optional[float] = None, + zoom: Optional[float] = None, ) -> None: await self._await( self._native.start_recording( - path, format, fps, speed, idle_time_limit + path, format, fps, speed, idle_time_limit, zoom ) ) diff --git a/bindings/python/stub-gen/src/main.rs b/bindings/python/stub-gen/src/main.rs index e0bd403f..75f18ea5 100644 --- a/bindings/python/stub-gen/src/main.rs +++ b/bindings/python/stub-gen/src/main.rs @@ -209,7 +209,12 @@ mod stubs { include_title: bool, cwd: typing.Optional[str], ) -> typing.Awaitable[str]: ... - def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... + def screenshot( + self, + path: typing.Optional[str], + full: bool, + zoom: typing.Optional[float], + ) -> typing.Awaitable[str]: ... def start_recording( self, path: str, @@ -217,6 +222,7 @@ mod stubs { fps: typing.Optional[int], speed: typing.Optional[float], idle_time_limit: typing.Optional[float], + zoom: typing.Optional[float], ) -> typing.Awaitable[None]: ... def stop_recording(self) -> typing.Awaitable[str]: ... def recording(self) -> typing.Awaitable[str]: ... diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index c6e2b353..f4c98b4f 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -82,8 +82,18 @@ async def scenario(): path = Path(root) / f"styled.{extension}" async with self._client() as su: await su.open(shell=SHELL, cols=20, rows=4) + if format == "apng": + screenshot = Path(root) / "zoomed.svg" + await su.screenshot( + str(screenshot), zoom=0.5 + ) + self.assertIn( + 'width="115" height="68" ' + 'viewBox="0 0 230 136"', + screenshot.read_text(encoding="utf-8"), + ) await su.start_recording( - str(path), format=format, fps=30 + str(path), format=format, fps=30, zoom=0.5 ) await su.submit(command) await su.wait_command() @@ -94,8 +104,20 @@ async def scenario(): if format == "apng": self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n") self.assertIn(b"acTL", data) + self.assertEqual( + int.from_bytes(data[16:20], "big"), 278 + ) + self.assertEqual( + int.from_bytes(data[20:24], "big"), 184 + ) else: self.assertEqual(data[:6], b"GIF89a") + self.assertEqual( + int.from_bytes(data[6:8], "little"), 278 + ) + self.assertEqual( + int.from_bytes(data[8:10], "little"), 184 + ) run(scenario()) diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index 71439f46..f9c458c5 100644 --- a/bindings/python/tests/test_options.py +++ b/bindings/python/tests/test_options.py @@ -228,11 +228,12 @@ def test_recording_helpers_use_typed_methods(self): terminal = _CapturingClient("s") run( terminal.start_recording( - "demo.cast", - format="cast", + "demo.png", + format="apng", fps=24, speed=2.0, idle_time_limit=3.0, + zoom=0.5, ) ) run(terminal.stop_recording()) @@ -241,12 +242,20 @@ def test_recording_helpers_use_typed_methods(self): [ ( "start_recording", - ("demo.cast", "cast", 24, 2.0, 3.0), + ("demo.png", "apng", 24, 2.0, 3.0, 0.5), ), ("stop_recording", ()), ], ) + def test_screenshot_forwards_zoom(self): + terminal = _CapturingClient("s") + run(terminal.screenshot("screen.svg", full=True, zoom=0.5)) + self.assertEqual( + terminal.fake.calls, + [("screenshot", ("screen.svg", True, 0.5))], + ) + class ClientTimeoutTests(unittest.TestCase): def test_unconfigured_waits_pass_none(self): diff --git a/crates/tui-test-cli/src/cli.rs b/crates/tui-test-cli/src/cli.rs index 5b3c1e7f..1cf14055 100644 --- a/crates/tui-test-cli/src/cli.rs +++ b/crates/tui-test-cli/src/cli.rs @@ -245,6 +245,9 @@ pub enum Command { /// Include scrollback, not just the visible viewport. #[arg(long)] full: bool, + /// Scale the SVG dimensions while keeping the same terminal cells. + #[arg(long)] + zoom: Option, }, /// Start or stop an animated terminal recording. Record { @@ -376,6 +379,9 @@ pub enum RecordCmd { /// Clamp idle gaps to this many seconds. #[arg(long)] idle_time_limit: Option, + /// Scale image/video dimensions while keeping the same terminal cells. + #[arg(long)] + zoom: Option, }, /// Stop the active recording and finish its output file. Stop, @@ -552,6 +558,8 @@ mod tests { "2", "--idle-time-limit", "3", + "--zoom", + "0.5", ]) .expect("parse recording start"); assert!(matches!( @@ -562,12 +570,33 @@ mod tests { fps: Some(24), speed: Some(2.0), idle_time_limit: Some(3.0), + zoom: Some(0.5), .. } }) )); } + #[test] + fn screenshot_accepts_zoom() { + let cli = Cli::try_parse_from([ + "tui-test", + "screenshot", + "--out", + "screen.svg", + "--zoom", + "0.5", + ]) + .expect("parse screenshot zoom"); + assert!(matches!( + cli.command, + Some(Command::Screenshot { + zoom: Some(0.5), + .. + }) + )); + } + #[test] fn open_has_no_catch_all_timeout_flag() { assert!(Cli::try_parse_from(["tui-test", "open", "--timeout", "1000"]).is_err()); diff --git a/crates/tui-test-cli/src/main.rs b/crates/tui-test-cli/src/main.rs index 13629dfc..59a82b01 100644 --- a/crates/tui-test-cli/src/main.rs +++ b/crates/tui-test-cli/src/main.rs @@ -207,9 +207,15 @@ fn build_request(command: Command) -> anyhow::Result { } Command::State => Request::State, Command::Text { full } => Request::Text { full }, - Command::Screenshot { path, out, full } => Request::Screenshot { + Command::Screenshot { + path, + out, + full, + zoom, + } => Request::Screenshot { full, path: out.or(path), + zoom, }, Command::Record { cmd: @@ -219,6 +225,7 @@ fn build_request(command: Command) -> anyhow::Result { fps, speed, idle_time_limit, + zoom, }, } => Request::StartRecording { path: resolve_client_path(path)?, @@ -226,6 +233,7 @@ fn build_request(command: Command) -> anyhow::Result { fps, speed, idle_time_limit, + zoom, }, Command::Record { cmd: RecordCmd::Stop, @@ -787,7 +795,7 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ [--config F] [--profile P]\n\ run [--config F] [--profile P] [args...]\n\ sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\ -INSPECT state | text [--full] | screenshot [-o file.svg] [--full]\n\ +INSPECT state | text [--full] | screenshot [-o file.svg] [--full] [--zoom N]\n\ cells X Y [W H] | get command|output|exit-code|cwd|cursor|size|title\n\ INPUT type \"text\" | submit [\"text\"] | press | keys \"Ctrl+a\"\n\ mouse click X Y | mouse click --on-text \"OK\" | mouse move|down|up|drag|scroll\n\ @@ -799,7 +807,7 @@ EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ expect title \"T\" [--regex --not --timeout MS]\n\ expect exit-code N | expect output \"T\" [--regex]\n\ expect snapshot NAME [-u] [--include-colors --include-title]\n\ -RECORD record start OUT [--format apng|gif|mp4|cast] [--fps N] [--speed N]\n\ +RECORD record start OUT [--format apng|gif|mp4|cast] [--fps N] [--speed N] [--zoom N]\n\ record stop | get-recording [session] > out.cast (always-on asciicast v2)\n\ WATCH monitor (live full-color view in another terminal; q/Esc/Ctrl-C to detach)\n\ AGENT agent-context (JSON cli schema) | skill [--add] (workflow guide)\n\ diff --git a/crates/tui-test-cli/src/protocol.rs b/crates/tui-test-cli/src/protocol.rs index f39a0cf5..0dc35bfa 100644 --- a/crates/tui-test-cli/src/protocol.rs +++ b/crates/tui-test-cli/src/protocol.rs @@ -136,6 +136,8 @@ pub enum Request { Screenshot { full: bool, path: Option, + #[serde(default)] + zoom: Option, }, StartRecording { path: String, @@ -143,6 +145,8 @@ pub enum Request { fps: Option, speed: Option, idle_time_limit: Option, + #[serde(default)] + zoom: Option, }, StopRecording, FlushRecording, @@ -300,19 +304,23 @@ impl Request { include_title, cwd, }), - Request::Screenshot { full, path } => Ok(Operation::Screenshot { full, path }), + Request::Screenshot { full, path, zoom } => { + Ok(Operation::Screenshot { full, path, zoom }) + } Request::StartRecording { path, format, fps, speed, idle_time_limit, + zoom, } => Ok(Operation::StartRecording { path, format, fps, speed, idle_time_limit, + zoom, }), Request::StopRecording => Ok(Operation::StopRecording), Request::Ping @@ -509,6 +517,24 @@ mod tests { } } + #[test] + fn image_zoom_is_optional_for_older_clients() { + let screenshot: Request = + serde_json::from_str(r#"{"kind":"screenshot","full":false,"path":"screen.svg"}"#) + .expect("deserialize legacy screenshot"); + assert!(matches!(screenshot, Request::Screenshot { zoom: None, .. })); + + let recording: Request = serde_json::from_str( + r#"{"kind":"start_recording","path":"demo.png","format":null, + "fps":null,"speed":null,"idle_time_limit":null}"#, + ) + .expect("deserialize legacy recording"); + assert!(matches!( + recording, + Request::StartRecording { zoom: None, .. } + )); + } + #[test] fn open_round_trips_session_timeout_defaults() { let timeouts = Timeouts { diff --git a/crates/tui-test-cli/tests/session_lifecycle.rs b/crates/tui-test-cli/tests/session_lifecycle.rs index 397ea485..ad2ddbdb 100644 --- a/crates/tui-test-cli/tests/session_lifecycle.rs +++ b/crates/tui-test-cli/tests/session_lifecycle.rs @@ -1163,13 +1163,23 @@ fn a_window_title_is_tracked_asserted_and_drawn() { // The title is drawn in the window chrome, not in the grid. let svg = sandbox.home.join("titled.svg"); - sandbox.ok(&["screenshot", "--out", svg.to_str().expect("utf-8 path")]); + sandbox.ok(&[ + "screenshot", + "--out", + svg.to_str().expect("utf-8 path"), + "--zoom", + "0.5", + ]); let image = std::fs::read_to_string(&svg).expect("read svg"); assert!( image.contains(">vim: notes.md - 40x30") && image.contains(r#"text-anchor="middle""#), "the title is drawn centred in the title bar: {image}" ); + assert!( + image.contains(r#"width="239" height="365" viewBox="0 0 478 730""#), + "zoom changes only the displayed dimensions: {image}" + ); // An empty title clears it, which is how programs tidy up on exit. sandbox.ok(&["submit", r#"printf '\033]2;\007'"#]); diff --git a/crates/tui-test/src/api.rs b/crates/tui-test/src/api.rs index f47640d1..37844fbf 100644 --- a/crates/tui-test/src/api.rs +++ b/crates/tui-test/src/api.rs @@ -181,6 +181,7 @@ pub enum Operation { Screenshot { full: bool, path: Option, + zoom: Option, }, StartRecording { path: String, @@ -188,6 +189,7 @@ pub enum Operation { fps: Option, speed: Option, idle_time_limit: Option, + zoom: Option, }, StopRecording, } @@ -416,6 +418,19 @@ impl RecordingFormat { } } +pub(crate) fn resolve_zoom(zoom: Option) -> Result { + let zoom = zoom.unwrap_or(1.0); + if !zoom.is_finite() || zoom <= 0.0 { + return Err(TuiTestError::usage( + "zoom must be finite and greater than zero", + )); + } + if zoom > f64::from(f32::MAX) / 2.0 { + return Err(TuiTestError::usage("zoom is too large")); + } + Ok(zoom) +} + #[derive(Debug, Clone, Serialize)] pub struct RuntimeStatus { pub session: String, @@ -497,4 +512,13 @@ mod tests { ); assert_eq!(RecordingFormat::infer("demo.webm"), None); } + + #[test] + fn zoom_defaults_to_one_and_rejects_invalid_values() { + assert_eq!(resolve_zoom(None).unwrap(), 1.0); + assert_eq!(resolve_zoom(Some(0.5)).unwrap(), 0.5); + for zoom in [0.0, -1.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] { + assert!(resolve_zoom(Some(zoom)).is_err()); + } + } } diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 7d87ac60..e3a8314d 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -690,8 +690,8 @@ fn dispatch( include_title, cwd, )?)), - Operation::Screenshot { full, path } => Ok(OperationResult::Screenshot(screenshot( - session, full, path, + Operation::Screenshot { full, path, zoom } => Ok(OperationResult::Screenshot(screenshot( + session, full, path, zoom, )?)), Operation::StartRecording { path, @@ -699,8 +699,9 @@ fn dispatch( fps, speed, idle_time_limit, + zoom, } => { - session.start_recording(path, format, fps, speed, idle_time_limit)?; + session.start_recording(path, format, fps, speed, idle_time_limit, zoom)?; Ok(OperationResult::Unit) } Operation::StopRecording => Ok(OperationResult::Recording(session.stop_recording()?)), @@ -1443,16 +1444,19 @@ fn screenshot( session: &TerminalSession, full: bool, path: Option, + zoom: Option, ) -> Result { + let zoom = crate::api::resolve_zoom(zoom)?; match path { Some(path) => { let snapshot = svg_snapshot(session, full); - let svg = crate::render::svg::render_svg( + let svg = crate::render::svg::render_svg_with_zoom( &snapshot.rows, snapshot.cols, &snapshot.render_state, snapshot.cursor, snapshot.title.as_deref(), + zoom, ); std::fs::write(&path, svg) .map_err(|error| TuiTestError::internal(error.to_string()))?; diff --git a/crates/tui-test/src/record.rs b/crates/tui-test/src/record.rs index 965a6b1c..bf602d8f 100644 --- a/crates/tui-test/src/record.rs +++ b/crates/tui-test/src/record.rs @@ -30,6 +30,7 @@ pub(crate) struct StartRecording { pub rows: u16, pub env: Vec<(String, String)>, pub initial_output: String, + pub zoom: f64, #[cfg(feature = "recording-raster")] pub timeline: frames::TimelineOptions, #[cfg(feature = "recording-raster")] @@ -41,6 +42,7 @@ pub(crate) struct StoppedRecording { #[cfg(feature = "recording-raster")] pub capture_path: PathBuf, pub format: RecordingFormat, + pub zoom: f64, #[cfg(feature = "recording-raster")] pub timeline: frames::TimelineOptions, #[cfg(feature = "recording-raster")] diff --git a/crates/tui-test/src/record/worker.rs b/crates/tui-test/src/record/worker.rs index fc7e2053..bb237eb5 100644 --- a/crates/tui-test/src/record/worker.rs +++ b/crates/tui-test/src/record/worker.rs @@ -116,6 +116,7 @@ pub(super) fn worker_loop( #[cfg(feature = "recording-raster")] capture_path: request.capture_path, format: request.format, + zoom: request.zoom, #[cfg(feature = "recording-raster")] timeline: request.timeline, #[cfg(feature = "recording-raster")] diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index eaabc373..df6644a5 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -51,7 +51,7 @@ pub trait FrameRenderer { pub struct GridRenderer { max_cols: u16, max_rows: usize, - scale: u32, + scale: f32, width: u32, height: u32, pixmap: Pixmap, @@ -64,30 +64,37 @@ impl GridRenderer { } pub fn with_scale(cols: u16, rows: usize, scale: u32) -> Self { - assert!(scale > 0, "recording raster scale must be non-zero"); + Self::with_zoom(cols, rows, f64::from(scale)) + .expect("recording raster scale must fit output dimensions") + } + + pub fn with_zoom(cols: u16, rows: usize, zoom: f64) -> 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 padding = CANVAS_PADDING - .checked_mul(scale) - .and_then(|padding| padding.checked_mul(2)) + .checked_mul(2) .expect("recording canvas padding must fit in u32"); let width = base_width - .checked_mul(scale) - .and_then(|width| width.checked_add(padding)) - .expect("recording width must fit in u32"); + .checked_add(padding) + .ok_or_else(|| anyhow::anyhow!("recording width must fit in u32"))?; let height = base_height - .checked_mul(scale) - .and_then(|height| height.checked_add(padding)) - .expect("recording height must fit in u32"); - Self { + .checked_add(padding) + .ok_or_else(|| anyhow::anyhow!("recording height must fit in u32"))?; + let width = scaled_dimension(width, zoom, "width")?; + let height = scaled_dimension(height, zoom, "height")?; + Ok(Self { max_cols: cols, max_rows: rows, - scale, + scale: zoom as f32, width, height, - pixmap: Pixmap::new(width, height) - .expect("terminal recording dimensions must fit a pixmap"), + pixmap: Pixmap::new(width, height).ok_or_else(|| { + anyhow::anyhow!("terminal recording dimensions must fit a pixmap") + })?, fonts: FontSystem::new(), - } + }) } } @@ -106,15 +113,11 @@ impl FrameRenderer for GridRenderer { anyhow::bail!("recording frame row exceeds its declared width"); } - let scale = self.scale as f32; + let scale = self.scale; let colors = &frame.render_state; let (base_width, base_height) = svg::pixel_size(cols, rows); - let panel_width = base_width - .checked_mul(self.scale) - .expect("recording frame width must fit in u32"); - let panel_height = base_height - .checked_mul(self.scale) - .expect("recording frame height must fit in u32"); + 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; let origin_y = (self.height - panel_height) as f32 / 2.0; self.pixmap.fill(tiny_skia::Color::from_rgba8( @@ -493,5 +496,13 @@ fn draw_title( } } +fn scaled_dimension(base: u32, zoom: f64, name: &str) -> anyhow::Result { + let scaled = f64::from(base) * zoom; + if !scaled.is_finite() || scaled > f64::from(u32::MAX) { + anyhow::bail!("recording {name} is too large"); + } + Ok(scaled.ceil().max(1.0) as u32) +} + #[cfg(test)] mod tests; diff --git a/crates/tui-test/src/render/raster/tests.rs b/crates/tui-test/src/render/raster/tests.rs index 7f4b6153..2adfdcb4 100644 --- a/crates/tui-test/src/render/raster/tests.rs +++ b/crates/tui-test/src/render/raster/tests.rs @@ -53,6 +53,30 @@ fn scaled_renderers_multiply_output_dimensions() { ); } +#[test] +fn fractional_zoom_shrinks_output_without_changing_grid_dimensions() { + let standard = GridRenderer::new(80, 30); + let half = GridRenderer::with_zoom(80, 30, 0.5).unwrap(); + assert_eq!( + half.pixel_size(), + ( + standard.pixel_size().0.div_ceil(2), + standard.pixel_size().1.div_ceil(2) + ) + ); + + let mut half = half; + half.render(&vec![vec![EmuCell::blank(); 80]; 30], 80) + .unwrap(); +} + +#[test] +fn invalid_zoom_is_rejected() { + for zoom in [0.0, -1.0, f64::INFINITY, f64::NAN] { + assert!(GridRenderer::with_zoom(1, 1, zoom).is_err()); + } +} + #[test] fn smaller_terminal_is_centered_on_the_recording_canvas() { let mut renderer = GridRenderer::new(4, 3); diff --git a/crates/tui-test/src/render/svg.rs b/crates/tui-test/src/render/svg.rs index cbbf923a..7e4e6e7a 100644 --- a/crates/tui-test/src/render/svg.rs +++ b/crates/tui-test/src/render/svg.rs @@ -58,6 +58,17 @@ fn hex(c: Rgb) -> String { c.to_hex() } +fn svg_dimension(value: f64) -> String { + let mut output = format!("{value:.4}"); + while output.contains('.') && output.ends_with('0') { + output.pop(); + } + if output.ends_with('.') { + output.pop(); + } + output +} + fn dim(c: Rgb) -> Rgb { let s = |v: u8| (v as f32 * 0.6) as u8; Rgb::new(s(c.r), s(c.g), s(c.b)) @@ -396,6 +407,7 @@ fn write_cursor( /// /// Its row is a `usize` because it indexes `rows`, which for a full-history /// render is as long as the scrollback and so is not bounded by the screen. +#[cfg(test)] pub(crate) fn render_svg( rows: &[Vec], cols: u16, @@ -403,7 +415,18 @@ pub(crate) fn render_svg( cursor: Option<(u16, usize)>, title: Option<&str>, ) -> String { - render_svg_with_font(rows, cols, colors, cursor, title, FONT_STACK) + render_svg_with_zoom(rows, cols, colors, cursor, title, 1.0) +} + +pub(crate) fn render_svg_with_zoom( + rows: &[Vec], + cols: u16, + colors: &dyn RenderColors, + cursor: Option<(u16, usize)>, + title: Option<&str>, + zoom: f64, +) -> String { + render_svg_with_font(rows, cols, colors, cursor, title, FONT_STACK, zoom) } pub(crate) fn render_svg_with_font( @@ -413,6 +436,7 @@ pub(crate) fn render_svg_with_font( cursor: Option<(u16, usize)>, title: Option<&str>, font_family: &str, + zoom: f64, ) -> String { let nerd_font = NerdFont::new(rows, FONT_SIZE); let cols = cols as usize; @@ -424,11 +448,13 @@ pub(crate) fn render_svg_with_font( let padding = CANVAS_PADDING as f32; let width = panel_width + padding * 2.0; let height = panel_height + padding * 2.0; + let output_width = svg_dimension(f64::from(width) * zoom); + let output_height = svg_dimension(f64::from(height) * zoom); let mut out = String::new(); let _ = write!( out, - r#""# + r#""# ); nerd_font.write_defs(&mut out); let _ = write!( @@ -823,6 +849,13 @@ mod tests { assert!(!svg.contains(", speed: Option, idle_time_limit: Option, + zoom: Option, ) -> Result<(), crate::api::TuiTestError> { if path.trim().is_empty() { return Err(crate::api::TuiTestError::usage( @@ -262,6 +263,12 @@ impl Session { "cannot infer recording format; use .png, .apng, .gif, .mp4, or .cast", ) })?; + let zoom = crate::api::resolve_zoom(zoom)?; + if format == crate::api::RecordingFormat::Cast && zoom != 1.0 { + return Err(crate::api::TuiTestError::usage( + "zoom is only supported for image and video recordings", + )); + } #[cfg(not(feature = "recording-raster"))] if format != crate::api::RecordingFormat::Cast { return Err(crate::api::TuiTestError::usage( @@ -317,6 +324,7 @@ impl Session { rows, env, initial_output, + zoom, #[cfg(feature = "recording-raster")] timeline: record::frames::TimelineOptions { fps, @@ -354,7 +362,8 @@ impl Session { let cast = record::cast::read(&stopped.capture_path)?; let frames = record::frames::from_cast(cast, &stopped.timeline)?; let (max_cols, max_rows) = record::frames::max_dimensions(&frames)?; - let mut renderer = GridRenderer::with_scale(max_cols, max_rows, 2); + let mut renderer = + GridRenderer::with_zoom(max_cols, max_rows, 2.0 * stopped.zoom)?; crate::render::encode::encode( &temporary_path, stopped.format, diff --git a/crates/tui-test/tests/runtime.rs b/crates/tui-test/tests/runtime.rs index 93b3d761..411ebacf 100644 --- a/crates/tui-test/tests/runtime.rs +++ b/crates/tui-test/tests/runtime.rs @@ -316,6 +316,7 @@ fn session_records_and_exports_an_apng() { fps: Some(30), speed: Some(1.0), idle_time_limit: Some(5.0), + zoom: Some(0.5), }) .expect("start recording"); session @@ -338,8 +339,8 @@ fn session_records_and_exports_an_apng() { let bytes = std::fs::read(&path).expect("read apng"); assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); assert!(bytes.windows(4).any(|window| window == b"acTL")); - assert_eq!(u32::from_be_bytes(bytes[16..20].try_into().unwrap()), 1756); - assert_eq!(u32::from_be_bytes(bytes[20..24].try_into().unwrap()), 1460); + assert_eq!(u32::from_be_bytes(bytes[16..20].try_into().unwrap()), 878); + assert_eq!(u32::from_be_bytes(bytes[20..24].try_into().unwrap()), 730); session.close().expect("close terminal"); std::fs::remove_file(path).expect("remove apng"); From 426edf012c2790624b3999537bc528c6fac51ccf Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 13 Aug 2026 21:57:14 -0700 Subject: [PATCH 2/5] fix: reject zoom for text screenshots --- bindings/js/src/client.ts | 3 +++ bindings/js/test/integration.test.mjs | 1 + bindings/python/src/tui_test/client.py | 2 ++ bindings/python/tests/test_options.py | 5 +++++ crates/tui-test-cli/src/main.rs | 22 +++++++++++++++++----- crates/tui-test/src/engine.rs | 5 ++++- crates/tui-test/src/record.rs | 2 ++ crates/tui-test/src/record/worker.rs | 1 + crates/tui-test/src/session.rs | 4 ++-- 9 files changed, 37 insertions(+), 8 deletions(-) diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index 2ce399cf..3a58b33f 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -351,6 +351,9 @@ export class TuiTest { } async screenshot(path: string | null = null, opts: ScreenshotOptions = {}): Promise { + if (opts.zoom !== undefined && path === null) { + throw new TypeError("screenshot zoom requires a path"); + } return this.#runtime.screenshot({ full: opts.full ?? false, path: optional(path), diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 0f462f48..0211a35e 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -51,6 +51,7 @@ test("echo roundtrip drives a real session", async () => { assert.deepEqual(await su.getSize(), { cols: 92, rows: 26 }); assert.ok((await su.cells(0, 0, 92, 26)).length > 0); assert.match(await su.screenshot(), /hello-sdk/); + await assert.rejects(() => su.screenshot(null, { zoom: 0.5 }), /requires a path/); await su.write("echo typed-write"); await su.keys("Enter"); diff --git a/bindings/python/src/tui_test/client.py b/bindings/python/src/tui_test/client.py index f55d580b..71d66c0c 100644 --- a/bindings/python/src/tui_test/client.py +++ b/bindings/python/src/tui_test/client.py @@ -374,6 +374,8 @@ async def screenshot( full: bool = False, zoom: Optional[float] = None, ) -> str: + if zoom is not None and path is None: + raise ValueError("screenshot zoom requires a path") return await self._await(self._native.screenshot(path, full, zoom)) async def start_recording( diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index f9c458c5..15c4a503 100644 --- a/bindings/python/tests/test_options.py +++ b/bindings/python/tests/test_options.py @@ -256,6 +256,11 @@ def test_screenshot_forwards_zoom(self): [("screenshot", ("screen.svg", True, 0.5))], ) + def test_screenshot_rejects_zoom_without_path(self): + terminal = _CapturingClient("s") + with self.assertRaisesRegex(ValueError, "requires a path"): + run(terminal.screenshot(zoom=0.5)) + class ClientTimeoutTests(unittest.TestCase): def test_unconfigured_waits_pass_none(self): diff --git a/crates/tui-test-cli/src/main.rs b/crates/tui-test-cli/src/main.rs index 59a82b01..f1731568 100644 --- a/crates/tui-test-cli/src/main.rs +++ b/crates/tui-test-cli/src/main.rs @@ -212,11 +212,13 @@ fn build_request(command: Command) -> anyhow::Result { out, full, zoom, - } => Request::Screenshot { - full, - path: out.or(path), - zoom, - }, + } => { + let path = out.or(path); + if zoom.is_some() && path.is_none() { + anyhow::bail!("screenshot --zoom requires --out or a path"); + } + Request::Screenshot { full, path, zoom } + } Command::Record { cmd: RecordCmd::Start { @@ -838,6 +840,16 @@ mod tests { ); } + #[test] + fn screenshot_zoom_without_output_is_rejected() { + let cli = Cli::try_parse_from(["tui-test", "screenshot", "--zoom", "0.5"]).unwrap(); + let command = cli.command.unwrap(); + assert!(build_request(command) + .unwrap_err() + .to_string() + .contains("requires --out")); + } + #[test] fn a_rich_payload_prints_its_fields_before_the_screen() { let rendered = format_data(&json!({ diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index e3a8314d..5e2d68d2 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -1446,9 +1446,9 @@ fn screenshot( path: Option, zoom: Option, ) -> Result { - let zoom = crate::api::resolve_zoom(zoom)?; match path { Some(path) => { + let zoom = crate::api::resolve_zoom(zoom)?; let snapshot = svg_snapshot(session, full); let svg = crate::render::svg::render_svg_with_zoom( &snapshot.rows, @@ -1462,6 +1462,9 @@ fn screenshot( .map_err(|error| TuiTestError::internal(error.to_string()))?; Ok(ScreenshotResult::Path(path)) } + None if zoom.is_some() => Err(TuiTestError::usage( + "screenshot zoom requires an output path", + )), None => Ok(ScreenshotResult::Text(text_of(&grid(session, full)))), } } diff --git a/crates/tui-test/src/record.rs b/crates/tui-test/src/record.rs index bf602d8f..5058e619 100644 --- a/crates/tui-test/src/record.rs +++ b/crates/tui-test/src/record.rs @@ -30,6 +30,7 @@ pub(crate) struct StartRecording { pub rows: u16, pub env: Vec<(String, String)>, pub initial_output: String, + #[cfg(feature = "recording-raster")] pub zoom: f64, #[cfg(feature = "recording-raster")] pub timeline: frames::TimelineOptions, @@ -42,6 +43,7 @@ pub(crate) struct StoppedRecording { #[cfg(feature = "recording-raster")] pub capture_path: PathBuf, pub format: RecordingFormat, + #[cfg(feature = "recording-raster")] pub zoom: f64, #[cfg(feature = "recording-raster")] pub timeline: frames::TimelineOptions, diff --git a/crates/tui-test/src/record/worker.rs b/crates/tui-test/src/record/worker.rs index bb237eb5..af347583 100644 --- a/crates/tui-test/src/record/worker.rs +++ b/crates/tui-test/src/record/worker.rs @@ -116,6 +116,7 @@ pub(super) fn worker_loop( #[cfg(feature = "recording-raster")] capture_path: request.capture_path, format: request.format, + #[cfg(feature = "recording-raster")] zoom: request.zoom, #[cfg(feature = "recording-raster")] timeline: request.timeline, diff --git a/crates/tui-test/src/session.rs b/crates/tui-test/src/session.rs index 1256eaa8..cbfa41ac 100644 --- a/crates/tui-test/src/session.rs +++ b/crates/tui-test/src/session.rs @@ -324,6 +324,7 @@ impl Session { rows, env, initial_output, + #[cfg(feature = "recording-raster")] zoom, #[cfg(feature = "recording-raster")] timeline: record::frames::TimelineOptions { @@ -362,8 +363,7 @@ impl Session { let cast = record::cast::read(&stopped.capture_path)?; let frames = record::frames::from_cast(cast, &stopped.timeline)?; let (max_cols, max_rows) = record::frames::max_dimensions(&frames)?; - let mut renderer = - GridRenderer::with_zoom(max_cols, max_rows, 2.0 * stopped.zoom)?; + let mut renderer = GridRenderer::with_zoom(max_cols, max_rows, 2.0 * stopped.zoom)?; crate::render::encode::encode( &temporary_path, stopped.format, From aa98a58c8f2657809f3d36764ba22e78151b6b16 Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 13 Aug 2026 22:29:16 -0700 Subject: [PATCH 3/5] fix: render zoom tests from complete frames --- crates/tui-test/src/render/raster/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui-test/src/render/raster/tests.rs b/crates/tui-test/src/render/raster/tests.rs index 2adfdcb4..e1dabe34 100644 --- a/crates/tui-test/src/render/raster/tests.rs +++ b/crates/tui-test/src/render/raster/tests.rs @@ -66,7 +66,7 @@ fn fractional_zoom_shrinks_output_without_changing_grid_dimensions() { ); let mut half = half; - half.render(&vec![vec![EmuCell::blank(); 80]; 30], 80) + half.render(&frame(vec![vec![EmuCell::blank(); 80]; 30])) .unwrap(); } From 941ba21fabb1ad8d60e6000da40efb952d194fbe Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 13 Aug 2026 23:40:53 -0700 Subject: [PATCH 4/5] test: initialize decoder recording zoom --- crates/tui-test/src/record.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tui-test/src/record.rs b/crates/tui-test/src/record.rs index 5058e619..ec6c8b31 100644 --- a/crates/tui-test/src/record.rs +++ b/crates/tui-test/src/record.rs @@ -233,6 +233,8 @@ mod tests { env: Vec::new(), initial_output: String::new(), #[cfg(feature = "recording-raster")] + zoom: 1.0, + #[cfg(feature = "recording-raster")] timeline: frames::TimelineOptions::default(), #[cfg(feature = "recording-raster")] ffmpeg_path: None, From f0df2e6981dc1dc4f934b435aaeecf347aa4710b Mon Sep 17 00:00:00 2001 From: cpendery Date: Fri, 14 Aug 2026 12:11:12 -0700 Subject: [PATCH 5/5] test: update zoomed SVG dimensions --- bindings/js/test/integration.test.mjs | 2 +- bindings/python/tests/test_integration.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 0211a35e..ae54ceb5 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -105,7 +105,7 @@ test("recording API exports styled Unicode to APNG and GIF", async () => { await su.screenshot(screenshotPath, { zoom: 0.5 }); assert.match( await readFile(screenshotPath, "utf8"), - /width="115" height="68" viewBox="0 0 230 136"/, + /width="139" height="92" viewBox="0 0 278 184"/, ); } await su.startRecording(path, { format, fps: 30, zoom: 0.5 }); diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index f4c98b4f..ef55b75f 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -88,8 +88,8 @@ async def scenario(): str(screenshot), zoom=0.5 ) self.assertIn( - 'width="115" height="68" ' - 'viewBox="0 0 230 136"', + 'width="139" height="92" ' + 'viewBox="0 0 278 184"', screenshot.read_text(encoding="utf-8"), ) await su.start_recording(