diff --git a/README.md b/README.md index a662f6e0..b44a9587 100644 --- a/README.md +++ b/README.md @@ -756,13 +756,19 @@ Type "You will see this being typed." ### Screenshot -The `Screenshot` command captures the current frame (png format). +The `Screenshot` command captures the current frame in either PNG or text format. ```elixir -# At any point... +# Capture as PNG image Screenshot examples/screenshot.png + +# Capture as plain text +Screenshot examples/screenshot.txt ``` +When using `.png` extension, VHS captures a visual screenshot of the terminal. +When using `.txt` extension, VHS captures the terminal content as plain text without any styling. + ### Copy / Paste The `Copy` and `Paste` copy and paste the string from clipboard. diff --git a/examples/text-screenshot.tape b/examples/text-screenshot.tape new file mode 100644 index 00000000..d3a21079 --- /dev/null +++ b/examples/text-screenshot.tape @@ -0,0 +1,21 @@ +Output test_output.gif + +Set FontSize 20 +Set Width 800 +Set Height 400 + +Type "echo 'Hello World'" +Enter +Sleep 0.5s + +Screenshot test_screenshot.txt + +Sleep 0.5s + +Type "ls -la" +Enter +Sleep 1s + +Screenshot test_screenshot2.png +Sleep 0.1s +Screenshot test_screenshot2.txt diff --git a/parser/parser.go b/parser/parser.go index 71ceeced..fdb5b3ce 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -755,10 +755,10 @@ func (p *Parser) parseScreenshot() Command { path := p.peek.Literal - // Check if path has .png extension + // Check if path has .png, .txt, or .ansi extension ext := filepath.Ext(path) - if ext != ".png" { - p.errors = append(p.errors, NewError(p.peek, "Expected file with .png extension")) + if ext != ".png" && ext != ".txt" && ext != ".ansi" { + p.errors = append(p.errors, NewError(p.peek, "Expected file with .png, .txt, or .ansi extension")) p.nextToken() return cmd } diff --git a/parser/parser_test.go b/parser/parser_test.go index aaccbc28..cb54003a 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -412,7 +412,7 @@ func TestParseScreeenshot(t *testing.T) { t.Run("should return error when screenshot extension is NOT (.png)", func(t *testing.T) { test := &parseScreenshotTest{ tape: "Screenshot step_one_screenshot.jpg", - errors: []string{"Expected file with .png extension"}, + errors: []string{"Expected file with .png, .txt, or .ansi extension"}, } test.run(t) diff --git a/screenshot.go b/screenshot.go index 03346447..1371341e 100644 --- a/screenshot.go +++ b/screenshot.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "os/exec" "path/filepath" ) @@ -17,6 +18,12 @@ type ScreenshotOptions struct { // screenshots represents a map holding screenshot path as key and frame as value. screenshots map[string]int + // textScreenshots represents a map holding text screenshot path as key and content as value. + textScreenshots map[string]string + + // ansiScreenshots represents a map holding ANSI screenshot path as key and content as value. + ansiScreenshots map[string]string + // Input represents location of cursor and text frames png files. input string @@ -29,6 +36,8 @@ func NewScreenshotOptions(input string, style *StyleOptions) ScreenshotOptions { frameCapture: false, nextScreenshotPath: "", screenshots: make(map[string]int), + textScreenshots: make(map[string]string), + ansiScreenshots: make(map[string]string), input: input, style: style, } @@ -43,6 +52,24 @@ func (opts *ScreenshotOptions) makeScreenshot(frame int) { opts.nextScreenshotPath = "" } +// makeTextScreenshot stores text content for a text screenshot. +// After storing content it disables frame capture. +func (opts *ScreenshotOptions) makeTextScreenshot(content string) { + opts.textScreenshots[opts.nextScreenshotPath] = content + + opts.frameCapture = false + opts.nextScreenshotPath = "" +} + +// makeAnsiScreenshot stores ANSI content for an ANSI screenshot. +// After storing content it disables frame capture. +func (opts *ScreenshotOptions) makeAnsiScreenshot(content string) { + opts.ansiScreenshots[opts.nextScreenshotPath] = content + + opts.frameCapture = false + opts.nextScreenshotPath = "" +} + // captureNextFrame prepares capture of next frame by given path. func (opts *ScreenshotOptions) enableFrameCapture(path string) { opts.frameCapture = true @@ -54,6 +81,12 @@ func MakeScreenshots(opts ScreenshotOptions) []*exec.Cmd { cmds := []*exec.Cmd{} for path, frame := range opts.screenshots { + // Handle text format screenshots differently + if filepath.Ext(path) == ".txt" { + // Text screenshots don't need ffmpeg, they'll be handled elsewhere + continue + } + cursorStream := filepath.Join(opts.input, fmt.Sprintf(cursorFrameFormat, frame)) textStream := filepath.Join(opts.input, fmt.Sprintf(textFrameFormat, frame)) @@ -99,3 +132,27 @@ func (opts *ScreenshotOptions) buildFFopts(targetFile, textStream, cursorStream return args } + +// MakeTextScreenshots writes text screenshots that were captured during recording. +func MakeTextScreenshots(opts ScreenshotOptions) error { + for path, content := range opts.textScreenshots { + // Write to file + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write text screenshot to %s: %w", path, err) + } + } + + return nil +} + +// MakeAnsiScreenshots writes ANSI screenshots that were captured during recording. +func MakeAnsiScreenshots(opts ScreenshotOptions) error { + for path, content := range opts.ansiScreenshots { + // Write to file + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write ANSI screenshot to %s: %w", path, err) + } + } + + return nil +} diff --git a/screenshot_test.go b/screenshot_test.go index b85b4a8f..4dcd066a 100644 --- a/screenshot_test.go +++ b/screenshot_test.go @@ -45,4 +45,18 @@ func TestScreenshot(t *testing.T) { t.Errorf("nextScreenshotPath: %s, expected: %s", opts.nextScreenshotPath, path) } }) + + t.Run("MakeTextScreenshots should process text screenshots", func(t *testing.T) { + opts := ScreenshotOptions{ + textScreenshots: map[string]string{ + "test.txt": "Hello World\nLine 2", + }, + } + + // This should not error because we're just writing files + err := MakeTextScreenshots(opts) + if err != nil { + t.Errorf("Expected no error when processing text screenshots, got: %v", err) + } + }) } diff --git a/testing.go b/testing.go index 103e5be3..74d0efd1 100644 --- a/testing.go +++ b/testing.go @@ -81,6 +81,56 @@ func (v *VHS) Buffer() ([]string, error) { return lines, nil } +// AnsiBuffer returns the current buffer with ANSI escape codes preserved. +func (v *VHS) AnsiBuffer() ([]string, error) { + // Get the current buffer with ANSI codes preserved using a different approach + // Since translateToString(true) might not work, we'll get the raw buffer data + buf, err := v.Page.Eval(`() => { + const lines = []; + for (let i = 0; i < term.rows; i++) { + const line = term.buffer.active.getLine(i); + if (line) { + // Get the line with formatting preserved + let lineStr = ''; + for (let j = 0; j < line.length; j++) { + const cell = line.getCell(j); + if (cell) { + // Add ANSI codes for foreground color + if (cell.getFgColor() !== 0) { + lineStr += '\033[38;5;' + cell.getFgColor() + 'm'; + } + // Add ANSI codes for background color + if (cell.getBgColor() !== 0) { + lineStr += '\033[48;5;' + cell.getBgColor() + 'm'; + } + // Add the character + lineStr += cell.getChars() || ' '; + // Reset if we had colors + if (cell.getFgColor() !== 0 || cell.getBgColor() !== 0) { + lineStr += '\033[0m'; + } + } + } + lines.push(lineStr.trimEnd()); + } else { + lines.push(''); + } + } + return lines; + }`) + if err != nil { + return nil, fmt.Errorf("read ANSI buffer: %w", err) + } + + arr := buf.Value.Arr() + lines := make([]string, 0, len(arr)) + for _, line := range arr { + lines = append(lines, line.Str()) + } + + return lines, nil +} + // CurrentLine returns the current line from the buffer. func (v *VHS) CurrentLine() (string, error) { buf, err := v.Page.Eval("() => term.buffer.active.getLine(term.buffer.active.cursorY+term.buffer.active.viewportY).translateToString().trimEnd()") diff --git a/vhs.go b/vhs.go index e302a6dc..e0340f6e 100644 --- a/vhs.go +++ b/vhs.go @@ -227,6 +227,16 @@ func (vhs *VHS) Render() error { } // Generate the video(s) with the frames. + // Generate text screenshots first + if err := MakeTextScreenshots(vhs.Options.Screenshot); err != nil { + return fmt.Errorf("failed to create text screenshots: %w", err) + } + + // Generate ANSI screenshots + if err := MakeAnsiScreenshots(vhs.Options.Screenshot); err != nil { + return fmt.Errorf("failed to create ANSI screenshots: %w", err) + } + var cmds []*exec.Cmd cmds = append(cmds, MakeGIF(vhs.Options.Video)) cmds = append(cmds, MakeMP4(vhs.Options.Video)) @@ -378,7 +388,28 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { // Capture current frame and disable frame capturing if vhs.Options.Screenshot.frameCapture { - vhs.Options.Screenshot.makeScreenshot(counter) + // Check if this is a text screenshot + if filepath.Ext(vhs.Options.Screenshot.nextScreenshotPath) == ".txt" { + // Get terminal buffer content + buffer, err := vhs.Buffer() + if err != nil { + ch <- fmt.Errorf("error capturing text screenshot: %w", err) + continue + } + content := strings.Join(buffer, "\n") + vhs.Options.Screenshot.makeTextScreenshot(content) + } else if filepath.Ext(vhs.Options.Screenshot.nextScreenshotPath) == ".ansi" { + // Get terminal buffer content with ANSI codes + buffer, err := vhs.AnsiBuffer() + if err != nil { + ch <- fmt.Errorf("error capturing ANSI screenshot: %w", err) + continue + } + content := strings.Join(buffer, "\n") + vhs.Options.Screenshot.makeAnsiScreenshot(content) + } else { + vhs.Options.Screenshot.makeScreenshot(counter) + } } } }