diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a2e94c6..8f32710c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,13 @@ jobs: env: GO111MODULE: "on" steps: + - name: Install deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libx11-dev xvfb + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + echo "DISPLAY=:99" >> $GITHUB_ENV - name: Install Go uses: actions/setup-go@v5 with: @@ -25,8 +32,8 @@ jobs: - name: Build run: go build -v ./... - #- name: Test - # run: go test -v -cover -timeout=30s ./... + - name: Test + run: go test -v -cover -timeout=30s ./... dependabot: needs: [build] runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0489e226..7775b04e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,6 +13,9 @@ jobs: name: lint runs-on: ubuntu-latest steps: + - name: Install deps + if: runner.os == 'Linux' + run: sudo apt install libx11-dev - name: Install Go uses: actions/setup-go@v5 with: diff --git a/README.md b/README.md index ad0ba773..e9a59a78 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,32 @@ freeze main.go --output out.webp freeze main.go --output out.{svg,png,webp} ``` +### Copy + +Copy the output image to your clipboard, so you can paste it anywhere. Freeze +uses `golang.design/clipboard` under the hood which supports macOS, Linux (x11), +and Windows. + +```bash +freeze main.go --output clipboard +``` + +#### Copy to clipboard (Wayland, x11) + +Since there are many different display servers out there that all manage +clipboards differently, you can pipe freeze images to your clipboard. + +> [!TIP] +> Freeze will automatically output images to stdout if it's used in a pipeline. + +``` bash +# x11 +freeze artichoke.hs | xclip -t image/png -selection clipboard + +# Wayland +wl-copy < $(freeze artichoke.hs) +``` + ### Font Specify the font family, font size, and font line height of the output image. diff --git a/configurations/full.json b/configurations/full.json index 40d51318..36f5fde3 100644 --- a/configurations/full.json +++ b/configurations/full.json @@ -29,5 +29,6 @@ "size": 14, "ligatures": true }, - "line_height": 1.2 -} \ No newline at end of file + "line_height": 1.2, + "copy": false +} diff --git a/freeze_test.go b/freeze_test.go index 53bd6c0d..24d57faf 100644 --- a/freeze_test.go +++ b/freeze_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/aymanbagabas/go-udiff" + "golang.design/x/clipboard" ) const binary = "./test/freeze-test" @@ -58,6 +59,30 @@ func TestFreezeOutput(t *testing.T) { } } +func TestClipboard(t *testing.T) { + // Clipboard can't be tested in headless CI. Only run this test with a + // real display. + if os.Getenv("CI") != "" { + t.Skip("Clipboard tests require real display server") + } + output := "clipboard" + defer os.Remove(output) + + cmd := exec.Command(binary, "test/input/bubbletea.model", "-o", output, "--language", "go", "--height", "800", "--width", "750", "--config", "full", "--window=false", "--show-line-numbers") + err := cmd.Run() + if err != nil { + t.Fatal(err) + } + err = clipboard.Init() + if err != nil { + t.Fatal(err) + } + png := clipboard.Read(clipboard.FmtImage) + if png == nil { + t.Fatal("clipboard is empty") + } +} + func TestFreezeHelp(t *testing.T) { out := bytes.Buffer{} cmd := exec.Command(binary) @@ -136,6 +161,15 @@ func TestFreezeConfigurations(t *testing.T) { flags: []string{"--language", "go", "--height", "800", "--width", "750", "--config", "full", "--window=false", "--show-line-numbers"}, output: "bubbletea", }, + { + input: "test/input/bubbletea.model", + flags: []string{"--language", "go", "--height", "800", "--width", "750", "--config", "full", "--window=false", "--show-line-numbers"}, + output: "bubbletea-copy", + }, + // { + // flags: []string{"--execute", "layout", "--height", "800", "--config", "full", "--margin", "50,10"}, + // output: "composite-2", + // }, { input: "test/input/layout.ansi", flags: []string{}, diff --git a/go.mod b/go.mod index 581a8861..5ba9b3ec 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/kanrichan/resvg-go v0.0.2-0.20231001163256-63db194ca9f5 github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-runewidth v0.0.16 + golang.design/x/clipboard v0.7.0 ) require ( @@ -49,6 +50,9 @@ require ( github.com/tetratelabs/wazero v1.8.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect + golang.org/x/exp/shiny v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/image v0.14.0 // indirect + golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect diff --git a/go.sum b/go.sum index 7bc22204..34adb65f 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,16 @@ github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmc github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= +golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp/shiny v0.0.0-20240506185415-9bf2ced13842 h1:kEvPiBVeT1JJGw/3THfe1W1zvTAvU1V6pCFV0icZvQs= +golang.org/x/exp/shiny v0.0.0-20240506185415-9bf2ced13842/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go index 53027730..c9003105 100644 --- a/main.go +++ b/main.go @@ -394,6 +394,9 @@ func main() { istty := isatty.IsTerminal(os.Stdout.Fd()) + if config.Output == "clipboard" { // convert to png because we can't copy svg to clipboard + config.Output = "clipboard.png" + } switch { case strings.HasSuffix(config.Output, ".png"): // use libsvg conversion. @@ -402,6 +405,9 @@ func main() { printFilenameOutput(config.Output) break } + if svgConversionErr != nil { + printErrorFatal("Oops, came across an error", svgConversionErr) + } // could not convert with libsvg, try resvg svgConversionErr = resvgConvert(doc, imageWidth, imageHeight, config.Output) @@ -413,6 +419,10 @@ func main() { default: // output file specified. if config.Output != "" { + _, err := doc.WriteToBytes() + if err != nil { + printErrorFatal("Unable to write output", err) + } err = doc.WriteToFile(config.Output) if err != nil { printErrorFatal("Unable to write output", err) diff --git a/png.go b/png.go index b7d01848..9b348162 100644 --- a/png.go +++ b/png.go @@ -5,12 +5,34 @@ import ( "context" "os" "os/exec" + "strings" "github.com/beevik/etree" "github.com/charmbracelet/freeze/font" "github.com/kanrichan/resvg-go" + "golang.design/x/clipboard" ) +func copyToClipboard(path string) error { + err := clipboard.Init() + if err != nil { + return err + } + // check if WAYLAND_DISPLAY is set + if os.Getenv("XDG_SESSION_TYPE") == "wayland" || os.Getenv("XDG_SESSION_TYPE") == "x11" { + printError("if you're using a display server like wayland or x11, use freeze in a pipeline to copy image contents to your clipboard. See the freeze README to learn more.", nil) + } + png, err := os.ReadFile(path) + defer os.Remove(path) // nolint: errcheck + if err != nil { + return err + } + + clipboard.Write(clipboard.FmtImage, png) + clipboard.Read(clipboard.FmtImage) + return err +} + func libsvgConvert(doc *etree.Document, _, _ float64, output string) error { _, err := exec.LookPath("rsvg-convert") if err != nil { @@ -27,6 +49,12 @@ func libsvgConvert(doc *etree.Document, _, _ float64, output string) error { rsvgConvert := exec.Command("rsvg-convert", "-o", output) rsvgConvert.Stdin = bytes.NewReader(svg) err = rsvgConvert.Run() + if err != nil { + return err + } + if strings.HasPrefix(output, "clipboard") { + return copyToClipboard(output) + } return err //nolint: wrapcheck } @@ -90,9 +118,8 @@ func resvgConvert(doc *etree.Document, w, h float64, output string) error { return err //nolint: wrapcheck } - err = os.WriteFile(output, png, 0o600) - if err != nil { - return err //nolint: wrapcheck + if output == "clipboard.png" { + return copyToClipboard(output) } - return err //nolint: wrapcheck + return os.WriteFile(output, png, 0o600) } diff --git a/test/golden/svg/bubbletea-copy.svg b/test/golden/svg/bubbletea-copy.svg new file mode 100644 index 00000000..40807bd2 --- /dev/null +++ b/test/golden/svg/bubbletea-copy.svg @@ -0,0 +1,49 @@ + + + + + + 1 func (m model) Init() tea.Cmd { + 2     return nil + 3 } + 4 + 5 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + 6     switch msg := msg.(type) { + 7 + 8     case tea.KeyMsg: + 9         switch msg.String() { + 10         case "ctrl+c", "q": + 11             return m, tea.Quit + 12         case "up", "k": + 13             if m.cursor > 0 { + 14                 m.cursor-- + 15             } + 16         case "down", "j": + 17             if m.cursor < len(m.choices)-1 { + 18                 m.cursor++ + 19             } + 20         case "enter", " ": + 21             _, ok := m.selected[m.cursor] + 22             if ok { + 23                 delete(m.selected, m.cursor) + 24             } else { + 25                 m.selected[m.cursor] = struct{}{} + 26             } + 27         } + 28     } + 29     return m, nil + 30 } + 31 + 32 func (m model) View() string { + 33     return // ... + 34 } + + +