detached 461ae32

feat(screenshot): copy PNG captures to the Wayland clipboard

commit
461ae32a8ed7bd8ad05b24c1e40a151657564487
author
brice · · 2026-09-30 14:15 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
111a4b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +622 -24
M .mockery.yml +1 -0
@@ -17,6 +17,7 @@ packages:
1717 interfaces:
1818 Source:
1919 ScreenshotWriter:
20+ Clipboard:
2021 VideoWriter:
2122 Selector:
2223 io:
@@ -17,6 +17,7 @@ packages:
17 interfaces:17 interfaces:
18 Source:18 Source:
19 ScreenshotWriter:19 ScreenshotWriter:
20+ Clipboard:
20 VideoWriter:21 VideoWriter:
21 Selector:22 Selector:
22 io:23 io:
M README.md +5 -1
@@ -7,6 +7,8 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell
77 ```sh
88 make bin
99 ./bin/nefercap shot
10+./bin/nefercap shot -clipboard
11+./bin/nefercap shot -clipboard -file capture.png
1012 ./bin/nefercap rec
1113 ./bin/nefercap stop
1214 ./bin/nefercap status
@@ -37,6 +39,8 @@ Monitor recording follows workspace changes. A workspace recording follows its s
3739
3840 Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path.
3941
42+`shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it.
43+
4044 Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border.
4145
4246 ## Requirements
@@ -59,7 +63,7 @@ A standard compositor can support monitor/region screenshots. Recording refuses
5963 ./bin/nefercap rec -size 1920x1080 -file presentation.mp4
6064 ```
6165
62-Use an output name from `outputs`. Scripted `screenshot` and `record` require `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor.
66+Use an output name from `outputs`. Scripted `screenshot` requires `-file` unless `-clipboard` is used; `record` always requires `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor.
6367
6468 `-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging.
6569
@@ -7,6 +7,8 @@ Fast Wayland screenshots and silent video recording with a NeferGUI layer-shell
7 ```sh7 ```sh
8 make bin8 make bin
9 ./bin/nefercap shot9 ./bin/nefercap shot
10+./bin/nefercap shot -clipboard
11+./bin/nefercap shot -clipboard -file capture.png
10 ./bin/nefercap rec12 ./bin/nefercap rec
11 ./bin/nefercap stop13 ./bin/nefercap stop
12 ./bin/nefercap status14 ./bin/nefercap status
@@ -37,6 +39,8 @@ Monitor recording follows workspace changes. A workspace recording follows its s
37 39
38 Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path.40 Screenshots go to the configured XDG Pictures directory's `Screenshots` subdirectory, and videos to the XDG Videos directory. Names include a nanosecond timestamp. `-file` chooses an explicit destination. Files are private to their owner and are never overwritten. The saved path is printed to stdout, including for scripted commands. Cancelling before any frame is written creates no file and prints no path.
39 41
42+`shot -clipboard` copies an `image/png` screenshot using `wl-copy` without creating a capture file or printing a path. Add `-file PATH` to save and copy the same PNG. A successfully saved file is retained and its path printed even if clipboard copying later fails. The clipboard owner intentionally survives nefercap's exit. This CLI-only option also works with scripted `screenshot`; other commands reject it. Install `wl-clipboard` to use it.
43+
40 Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border.44 Opening screenshot selection while a recording is active is refused: its overlay is not part of the existing session's authorized controls. A scripted `screenshot` still uses standard capture and includes the active HUD and border.
41 45
42 ## Requirements46 ## Requirements
@@ -59,7 +63,7 @@ A standard compositor can support monitor/region screenshots. Recording refuses
59 ./bin/nefercap rec -size 1920x1080 -file presentation.mp463 ./bin/nefercap rec -size 1920x1080 -file presentation.mp4
60 ```64 ```
61 65
62-Use an output name from `outputs`. Scripted `screenshot` and `record` require `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor.66+Use an output name from `outputs`. Scripted `screenshot` requires `-file` unless `-clipboard` is used; `record` always requires `-file`; omitting `-output` is allowed with exactly one output. Regions use output-local logical coordinates, not desktop-global coordinates. Capture dimensions are physical pixels negotiated with the compositor.
63 67
64 `-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging.68 `-fps` accepts 1–120, default 30. `-size` requires two even dimensions and changes encoded resolution, not application scaling. Odd source dimensions require an explicit even size; pixels are not silently cropped. `-debug` enables diagnostic logging.
65 69
M internal/adapters/capturefile/capturefile.go +10 -1
@@ -22,7 +22,16 @@ type Reservation struct {
2222 // error matching both ports.ErrPathExists and fs.ErrExist. The returned
2323 // Reservation records the file's identity for a later Remove.
2424 func Create(path string) (*os.File, *Reservation, error) {
25- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
25+ return create(path, os.O_WRONLY)
26+}
27+
28+// CreateReadable reserves a file whose original descriptor can be reused for copying.
29+func CreateReadable(path string) (*os.File, *Reservation, error) {
30+ return create(path, os.O_RDWR)
31+}
32+
33+func create(path string, access int) (*os.File, *Reservation, error) {
34+ f, err := os.OpenFile(path, access|os.O_CREATE|os.O_EXCL, 0o600)
2635 if err != nil {
2736 if errors.Is(err, fs.ErrExist) {
2837 return nil, nil, fmt.Errorf("%w: %w", ports.ErrPathExists, err)
@@ -22,7 +22,16 @@ type Reservation struct {
22 // error matching both ports.ErrPathExists and fs.ErrExist. The returned22 // error matching both ports.ErrPathExists and fs.ErrExist. The returned
23 // Reservation records the file's identity for a later Remove.23 // Reservation records the file's identity for a later Remove.
24 func Create(path string) (*os.File, *Reservation, error) {24 func Create(path string) (*os.File, *Reservation, error) {
25- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)25+ return create(path, os.O_WRONLY)
26+}
27+
28+// CreateReadable reserves a file whose original descriptor can be reused for copying.
29+func CreateReadable(path string) (*os.File, *Reservation, error) {
30+ return create(path, os.O_RDWR)
31+}
32+
33+func create(path string, access int) (*os.File, *Reservation, error) {
34+ f, err := os.OpenFile(path, access|os.O_CREATE|os.O_EXCL, 0o600)
26 if err != nil {35 if err != nil {
27 if errors.Is(err, fs.ErrExist) {36 if errors.Is(err, fs.ErrExist) {
28 return nil, nil, fmt.Errorf("%w: %w", ports.ErrPathExists, err)37 return nil, nil, fmt.Errorf("%w: %w", ports.ErrPathExists, err)
M internal/adapters/cli/cli.go +15 -10
@@ -42,13 +42,14 @@ var ErrUsage = errors.New("usage error")
4242 // Path is optional for Shot and Rec, where an empty Path lets the caller
4343 // choose one.
4444 type Options struct {
45- Command Command
46- Output string // output name; empty lets the caller choose
47- Region ports.Region
48- Path string
49- Video ports.VideoSettings
50- Duration time.Duration
51- Debug bool
45+ Command Command
46+ Output string // output name; empty lets the caller choose
47+ Region ports.Region
48+ Path string
49+ Video ports.VideoSettings
50+ Duration time.Duration
51+ Debug bool
52+ Clipboard bool
5253 }
5354
5455 // Usage describes the command line.
@@ -80,9 +81,10 @@ Script commands (never open the selector):
8081 record record silent video until interrupted or target video length is reached
8182
8283 Flags:
84+ -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot)
8385 -debug enable debug logging (all commands)
8486 -output NAME output name (all except stop, status, outputs)
85- -file PATH new file to write; optional for shot, rec, required for screenshot, record
87+ -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it
8688 -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
8789 -fps N frames per second, 1..%d (rec, record, default %d)
8890 -size WxH scaled video size, both even, at most %d each (rec, record, default source size)
@@ -123,6 +125,9 @@ func Parse(args []string, out io.Writer) (Options, error) {
123125 if scripted {
124126 fs.StringVar(&region, "region", "", "")
125127 }
128+ if opts.Command == Shot || opts.Command == Screenshot {
129+ fs.BoolVar(&opts.Clipboard, "clipboard", false, "")
130+ }
126131 if video {
127132 fs.IntVar(&fps, "fps", DefaultFPS, "")
128133 fs.StringVar(&size, "size", "", "")
@@ -143,10 +148,10 @@ func Parse(args []string, out io.Writer) (Options, error) {
143148 return opts, nil
144149 }
145150
146- if scripted && opts.Path == "" {
151+ if scripted && opts.Path == "" && !opts.Clipboard {
147152 return Options{}, usageErr("-file is required")
148153 }
149- if interactive {
154+ if interactive || opts.Clipboard {
150155 // Set explicitly so "-file ''" is rejected rather than treated as auto.
151156 explicit := false
152157 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" })
@@ -42,13 +42,14 @@ var ErrUsage = errors.New("usage error")
42 // Path is optional for Shot and Rec, where an empty Path lets the caller42 // Path is optional for Shot and Rec, where an empty Path lets the caller
43 // choose one.43 // choose one.
44 type Options struct {44 type Options struct {
45- Command Command45+ Command Command
46- Output string // output name; empty lets the caller choose46+ Output string // output name; empty lets the caller choose
47- Region ports.Region47+ Region ports.Region
48- Path string48+ Path string
49- Video ports.VideoSettings49+ Video ports.VideoSettings
50- Duration time.Duration50+ Duration time.Duration
51- Debug bool51+ Debug bool
52+ Clipboard bool
52 }53 }
53 54
54 // Usage describes the command line.55 // Usage describes the command line.
@@ -80,9 +81,10 @@ Script commands (never open the selector):
80 record record silent video until interrupted or target video length is reached81 record record silent video until interrupted or target video length is reached
81 82
82 Flags:83 Flags:
84+ -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot)
83 -debug enable debug logging (all commands)85 -debug enable debug logging (all commands)
84 -output NAME output name (all except stop, status, outputs)86 -output NAME output name (all except stop, status, outputs)
85- -file PATH new file to write; optional for shot, rec, required for screenshot, record87+ -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it
86 -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)88 -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
87 -fps N frames per second, 1..%d (rec, record, default %d)89 -fps N frames per second, 1..%d (rec, record, default %d)
88 -size WxH scaled video size, both even, at most %d each (rec, record, default source size)90 -size WxH scaled video size, both even, at most %d each (rec, record, default source size)
@@ -123,6 +125,9 @@ func Parse(args []string, out io.Writer) (Options, error) {
123 if scripted {125 if scripted {
124 fs.StringVar(&region, "region", "", "")126 fs.StringVar(&region, "region", "", "")
125 }127 }
128+ if opts.Command == Shot || opts.Command == Screenshot {
129+ fs.BoolVar(&opts.Clipboard, "clipboard", false, "")
130+ }
126 if video {131 if video {
127 fs.IntVar(&fps, "fps", DefaultFPS, "")132 fs.IntVar(&fps, "fps", DefaultFPS, "")
128 fs.StringVar(&size, "size", "", "")133 fs.StringVar(&size, "size", "", "")
@@ -143,10 +148,10 @@ func Parse(args []string, out io.Writer) (Options, error) {
143 return opts, nil148 return opts, nil
144 }149 }
145 150
146- if scripted && opts.Path == "" {151+ if scripted && opts.Path == "" && !opts.Clipboard {
147 return Options{}, usageErr("-file is required")152 return Options{}, usageErr("-file is required")
148 }153 }
149- if interactive {154+ if interactive || opts.Clipboard {
150 // Set explicitly so "-file ''" is rejected rather than treated as auto.155 // Set explicitly so "-file ''" is rejected rather than treated as auto.
151 explicit := false156 explicit := false
152 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" })157 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" })
A internal/adapters/cli/clipboard_test.go +26 -0
@@ -0,0 +1,26 @@
1+package cli
2+
3+import (
4+ "github.com/stretchr/testify/require"
5+ "io"
6+ "testing"
7+)
8+
9+func TestClipboardFlag(t *testing.T) {
10+ for _, cmd := range []string{"shot", "screenshot"} {
11+ opts, err := Parse([]string{cmd, "-clipboard"}, io.Discard)
12+ require.NoError(t, err)
13+ require.True(t, opts.Clipboard)
14+ require.Empty(t, opts.Path)
15+ opts, err = Parse([]string{cmd, "-clipboard", "-file", "shot.png"}, io.Discard)
16+ require.NoError(t, err)
17+ require.True(t, opts.Clipboard)
18+ require.Equal(t, "shot.png", opts.Path)
19+ _, err = Parse([]string{cmd, "-clipboard", "-file", ""}, io.Discard)
20+ require.ErrorIs(t, err, ErrUsage)
21+ }
22+ for _, cmd := range []string{"rec", "record", "stop", "status", "outputs"} {
23+ _, err := Parse([]string{cmd, "-clipboard"}, io.Discard)
24+ require.ErrorIs(t, err, ErrUsage)
25+ }
26+}
@@ -0,0 +1,26 @@
1+package cli
2+
3+import (
4+ "github.com/stretchr/testify/require"
5+ "io"
6+ "testing"
7+)
8+
9+func TestClipboardFlag(t *testing.T) {
10+ for _, cmd := range []string{"shot", "screenshot"} {
11+ opts, err := Parse([]string{cmd, "-clipboard"}, io.Discard)
12+ require.NoError(t, err)
13+ require.True(t, opts.Clipboard)
14+ require.Empty(t, opts.Path)
15+ opts, err = Parse([]string{cmd, "-clipboard", "-file", "shot.png"}, io.Discard)
16+ require.NoError(t, err)
17+ require.True(t, opts.Clipboard)
18+ require.Equal(t, "shot.png", opts.Path)
19+ _, err = Parse([]string{cmd, "-clipboard", "-file", ""}, io.Discard)
20+ require.ErrorIs(t, err, ErrUsage)
21+ }
22+ for _, cmd := range []string{"rec", "record", "stop", "status", "outputs"} {
23+ _, err := Parse([]string{cmd, "-clipboard"}, io.Discard)
24+ require.ErrorIs(t, err, ErrUsage)
25+ }
26+}
A internal/adapters/clipboard/clipboard.go +127 -0
@@ -0,0 +1,127 @@
1+// Package clipboard streams PNG screenshots to the native Wayland clipboard.
2+package clipboard
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "time"
12+
13+ "github.com/bnema/nefercap/internal/adapters/capturefile"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// Native uses wl-copy's default fork: the clipboard owner intentionally survives
18+// nefercap. Only the initial handoff is bounded; never use --foreground here.
19+type Native struct{}
20+
21+func New() *Native { return &Native{} }
22+
23+func (*Native) Copy(ctx context.Context, r io.Reader) error {
24+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
25+ defer cancel()
26+ if err := ctx.Err(); err != nil {
27+ return err
28+ }
29+ cmd := exec.CommandContext(ctx, "wl-copy", "--type", "image/png")
30+ cmd.Stdin = r
31+ // Give wl-copy a real descriptor: its forked owner inherits stderr, so
32+ // exec's automatic stderr copier would wait for that long-lived owner.
33+ rd, wr, err := os.Pipe()
34+ if err != nil {
35+ return fmt.Errorf("wl-copy stderr: %w", err)
36+ }
37+ stderr := &limitedError{}
38+ drained := make(chan struct{})
39+ go func() { _, _ = io.Copy(stderr, rd); close(drained) }()
40+ cmd.Stderr = wr
41+ cmd.WaitDelay = 200 * time.Millisecond
42+ err = cmd.Run()
43+ _ = wr.Close()
44+ // Drain queued diagnostics, but never wait for the forked owner to close.
45+ if deadlineErr := rd.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); deadlineErr != nil {
46+ _ = rd.Close()
47+ }
48+ <-drained
49+ _ = rd.Close()
50+ if err != nil {
51+ if ctx.Err() != nil {
52+ return fmt.Errorf("wl-copy: %w", ctx.Err())
53+ }
54+ if errors.Is(err, exec.ErrNotFound) {
55+ return fmt.Errorf("wl-copy not found: install wl-clipboard: %w", err)
56+ }
57+ return fmt.Errorf("wl-copy: %w: %s", err, stderr.data)
58+ }
59+ return nil
60+}
61+
62+type limitedError struct{ data []byte }
63+
64+func (b *limitedError) Write(p []byte) (int, error) {
65+ n := len(p)
66+ remaining := 4096 - len(b.data)
67+ b.data = append(b.data, p[:min(remaining, len(p))]...)
68+ return n, nil
69+}
70+
71+// pngStream keeps diagnostic formatters from inspecting a concurrently active pipe.
72+type pngStream struct{ io.Reader }
73+
74+func (pngStream) String() string { return "PNG stream" }
75+
76+// Writer composes the existing PNG codec and clipboard port. Saved is set only
77+// after encoding a valid file, and survives clipboard failures.
78+// Each Writer belongs to one screenshot invocation.
79+type Writer struct {
80+ codec ports.ScreenshotWriter
81+ clipboard ports.Clipboard
82+ Saved bool
83+}
84+
85+func NewWriter(codec ports.ScreenshotWriter, clipboard ports.Clipboard) *Writer {
86+ return &Writer{codec: codec, clipboard: clipboard}
87+}
88+
89+func (w *Writer) Encode(ctx context.Context, out io.Writer, f ports.Frame) error {
90+ return w.codec.Encode(ctx, out, f)
91+}
92+
93+func (w *Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) {
94+ w.Saved = false
95+ if err := ctx.Err(); err != nil {
96+ return err
97+ }
98+ if err := f.Validate(); err != nil {
99+ return err
100+ }
101+ if path == "" {
102+ r, out := io.Pipe()
103+ encoded := make(chan error, 1)
104+ go func() {
105+ err := w.codec.Encode(ctx, out, f)
106+ _ = out.CloseWithError(err)
107+ encoded <- err
108+ }()
109+ copyErr := w.clipboard.Copy(ctx, pngStream{r})
110+ _ = r.CloseWithError(copyErr)
111+ return errors.Join(<-encoded, copyErr)
112+ }
113+ file, reservation, err := capturefile.CreateReadable(path)
114+ if err != nil {
115+ return err
116+ }
117+ defer func() { err = errors.Join(err, file.Close()) }()
118+ if err = w.codec.Encode(ctx, file, f); err != nil {
119+ return errors.Join(err, reservation.Remove())
120+ }
121+ // Reuse the original descriptor, never reopen a potentially replaced path.
122+ w.Saved = true
123+ if _, err = file.Seek(0, io.SeekStart); err != nil {
124+ return err
125+ }
126+ return w.clipboard.Copy(ctx, file)
127+}
@@ -0,0 +1,127 @@
1+// Package clipboard streams PNG screenshots to the native Wayland clipboard.
2+package clipboard
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "time"
12+
13+ "github.com/bnema/nefercap/internal/adapters/capturefile"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// Native uses wl-copy's default fork: the clipboard owner intentionally survives
18+// nefercap. Only the initial handoff is bounded; never use --foreground here.
19+type Native struct{}
20+
21+func New() *Native { return &Native{} }
22+
23+func (*Native) Copy(ctx context.Context, r io.Reader) error {
24+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
25+ defer cancel()
26+ if err := ctx.Err(); err != nil {
27+ return err
28+ }
29+ cmd := exec.CommandContext(ctx, "wl-copy", "--type", "image/png")
30+ cmd.Stdin = r
31+ // Give wl-copy a real descriptor: its forked owner inherits stderr, so
32+ // exec's automatic stderr copier would wait for that long-lived owner.
33+ rd, wr, err := os.Pipe()
34+ if err != nil {
35+ return fmt.Errorf("wl-copy stderr: %w", err)
36+ }
37+ stderr := &limitedError{}
38+ drained := make(chan struct{})
39+ go func() { _, _ = io.Copy(stderr, rd); close(drained) }()
40+ cmd.Stderr = wr
41+ cmd.WaitDelay = 200 * time.Millisecond
42+ err = cmd.Run()
43+ _ = wr.Close()
44+ // Drain queued diagnostics, but never wait for the forked owner to close.
45+ if deadlineErr := rd.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); deadlineErr != nil {
46+ _ = rd.Close()
47+ }
48+ <-drained
49+ _ = rd.Close()
50+ if err != nil {
51+ if ctx.Err() != nil {
52+ return fmt.Errorf("wl-copy: %w", ctx.Err())
53+ }
54+ if errors.Is(err, exec.ErrNotFound) {
55+ return fmt.Errorf("wl-copy not found: install wl-clipboard: %w", err)
56+ }
57+ return fmt.Errorf("wl-copy: %w: %s", err, stderr.data)
58+ }
59+ return nil
60+}
61+
62+type limitedError struct{ data []byte }
63+
64+func (b *limitedError) Write(p []byte) (int, error) {
65+ n := len(p)
66+ remaining := 4096 - len(b.data)
67+ b.data = append(b.data, p[:min(remaining, len(p))]...)
68+ return n, nil
69+}
70+
71+// pngStream keeps diagnostic formatters from inspecting a concurrently active pipe.
72+type pngStream struct{ io.Reader }
73+
74+func (pngStream) String() string { return "PNG stream" }
75+
76+// Writer composes the existing PNG codec and clipboard port. Saved is set only
77+// after encoding a valid file, and survives clipboard failures.
78+// Each Writer belongs to one screenshot invocation.
79+type Writer struct {
80+ codec ports.ScreenshotWriter
81+ clipboard ports.Clipboard
82+ Saved bool
83+}
84+
85+func NewWriter(codec ports.ScreenshotWriter, clipboard ports.Clipboard) *Writer {
86+ return &Writer{codec: codec, clipboard: clipboard}
87+}
88+
89+func (w *Writer) Encode(ctx context.Context, out io.Writer, f ports.Frame) error {
90+ return w.codec.Encode(ctx, out, f)
91+}
92+
93+func (w *Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) {
94+ w.Saved = false
95+ if err := ctx.Err(); err != nil {
96+ return err
97+ }
98+ if err := f.Validate(); err != nil {
99+ return err
100+ }
101+ if path == "" {
102+ r, out := io.Pipe()
103+ encoded := make(chan error, 1)
104+ go func() {
105+ err := w.codec.Encode(ctx, out, f)
106+ _ = out.CloseWithError(err)
107+ encoded <- err
108+ }()
109+ copyErr := w.clipboard.Copy(ctx, pngStream{r})
110+ _ = r.CloseWithError(copyErr)
111+ return errors.Join(<-encoded, copyErr)
112+ }
113+ file, reservation, err := capturefile.CreateReadable(path)
114+ if err != nil {
115+ return err
116+ }
117+ defer func() { err = errors.Join(err, file.Close()) }()
118+ if err = w.codec.Encode(ctx, file, f); err != nil {
119+ return errors.Join(err, reservation.Remove())
120+ }
121+ // Reuse the original descriptor, never reopen a potentially replaced path.
122+ w.Saved = true
123+ if _, err = file.Seek(0, io.SeekStart); err != nil {
124+ return err
125+ }
126+ return w.clipboard.Copy(ctx, file)
127+}
A internal/adapters/clipboard/clipboard_test.go +192 -0
@@ -0,0 +1,192 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefercap/internal/adapters/png"
14+ mocks "github.com/bnema/nefercap/internal/mocks/ports"
15+ "github.com/bnema/nefercap/internal/ports"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+var frame = ports.Frame{Pixels: []byte{1, 2, 3, 0}, Width: 1, Height: 1, Stride: 4, Format: ports.XRGB8888}
21+
22+func TestWriterDestinations(t *testing.T) {
23+ for _, both := range []bool{false, true} {
24+ t.Run(map[bool]string{false: "clipboard only", true: "file and clipboard"}[both], func(t *testing.T) {
25+ dir := t.TempDir()
26+ path := ""
27+ if both {
28+ path = filepath.Join(dir, "shot.png")
29+ }
30+ clip := mocks.NewMockClipboard(t)
31+ var data []byte
32+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error {
33+ var err error
34+ data, err = io.ReadAll(r)
35+ return err
36+ }).Once()
37+ w := NewWriter(png.New(), clip)
38+ require.NoError(t, w.Save(context.Background(), frame, path))
39+ require.Equal(t, both, w.Saved)
40+ require.True(t, strings.HasPrefix(string(data), "\x89PNG"))
41+ if both {
42+ disk, err := os.ReadFile(path)
43+ require.NoError(t, err)
44+ require.Equal(t, disk, data)
45+ } else {
46+ files, err := os.ReadDir(dir)
47+ require.NoError(t, err)
48+ require.Empty(t, files)
49+ }
50+ })
51+ }
52+}
53+
54+func TestSavedFileSurvivesClipboardFailureAndReplacement(t *testing.T) {
55+ path := filepath.Join(t.TempDir(), "shot.png")
56+ clip := mocks.NewMockClipboard(t)
57+ failure := errors.New("clipboard unavailable")
58+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error {
59+ require.NoError(t, os.Rename(path, path+".saved"))
60+ require.NoError(t, os.Symlink(path+".replacement", path))
61+ data, err := io.ReadAll(r)
62+ require.NoError(t, err)
63+ require.True(t, strings.HasPrefix(string(data), "\x89PNG"))
64+ return failure
65+ }).Once()
66+ w := NewWriter(png.New(), clip)
67+ require.ErrorIs(t, w.Save(context.Background(), frame, path), failure)
68+ require.True(t, w.Saved)
69+ disk, err := os.ReadFile(path + ".saved")
70+ require.NoError(t, err)
71+ require.True(t, strings.HasPrefix(string(disk), "\x89PNG"))
72+}
73+
74+func TestWriterRejectsBeforeClipboard(t *testing.T) {
75+ clip := mocks.NewMockClipboard(t)
76+ w := NewWriter(png.New(), clip)
77+ ctx, cancel := context.WithCancel(context.Background())
78+ cancel()
79+ path := filepath.Join(t.TempDir(), "shot.png")
80+ require.ErrorIs(t, w.Save(ctx, frame, path), context.Canceled)
81+ require.Error(t, w.Save(context.Background(), ports.Frame{}, path))
82+ require.NoFileExists(t, path)
83+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0600))
84+ require.ErrorIs(t, w.Save(context.Background(), frame, path), ports.ErrPathExists)
85+ disk, err := os.ReadFile(path)
86+ require.NoError(t, err)
87+ require.Equal(t, "keep", string(disk))
88+}
89+
90+func TestEncodeFailureAndEarlyClipboardExit(t *testing.T) {
91+ for _, path := range []string{"", filepath.Join(t.TempDir(), "failed.png")} {
92+ codec := mocks.NewMockScreenshotWriter(t)
93+ clip := mocks.NewMockClipboard(t)
94+ failure := errors.New("encode failed")
95+ codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).Return(failure).Once()
96+ if path == "" {
97+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }).Once()
98+ }
99+ w := NewWriter(codec, clip)
100+ require.ErrorIs(t, w.Save(context.Background(), frame, path), failure)
101+ require.False(t, w.Saved)
102+ if path != "" {
103+ require.NoFileExists(t, path)
104+ }
105+ }
106+ clip := mocks.NewMockClipboard(t)
107+ failure := errors.New("early exit")
108+ clip.EXPECT().Copy(mock.Anything, mock.Anything).Return(failure).Once()
109+ require.ErrorIs(t, NewWriter(png.New(), clip).Save(context.Background(), frame, ""), failure)
110+}
111+
112+// Real executable fixtures exercise os/exec without a handwritten process fake.
113+func TestNativeProcessFailures(t *testing.T) {
114+ dir := t.TempDir()
115+ t.Setenv("PATH", dir)
116+ require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "install wl-clipboard")
117+ executable := filepath.Join(dir, "wl-copy")
118+ require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nprintf 'no compositor' >&2\nexit 7\n"), 0700))
119+ require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "no compositor")
120+ require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nexec /bin/sleep 10\n"), 0700))
121+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
122+ defer cancel()
123+ began := time.Now()
124+ require.ErrorIs(t, New().Copy(ctx, strings.NewReader("PNG")), context.DeadlineExceeded)
125+ require.Less(t, time.Since(began), time.Second)
126+}
127+
128+func TestNativeSuccessfulForkDoesNotWaitForStderrOwner(t *testing.T) {
129+ dir := t.TempDir()
130+ t.Setenv("PATH", dir)
131+ // This is a real child process, retaining only stderr after handoff.
132+ executable := filepath.Join(dir, "wl-copy")
133+ require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\n/bin/cat >/dev/null\n/bin/sleep 1 >/dev/null 2>&2 &\nexit 0\n"), 0o700))
134+ began := time.Now()
135+ require.NoError(t, New().Copy(context.Background(), strings.NewReader("PNG")))
136+ require.Less(t, time.Since(began), 500*time.Millisecond)
137+}
138+
139+func TestCopyCancellationJoinsEncoder(t *testing.T) {
140+ codec := mocks.NewMockScreenshotWriter(t)
141+ clip := mocks.NewMockClipboard(t)
142+ started, encoded := make(chan struct{}), make(chan struct{})
143+ codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).RunAndReturn(func(_ context.Context, out io.Writer, _ ports.Frame) error {
144+ close(started)
145+ _, err := out.Write([]byte("PNG")) // backpressure until the reader is closed
146+ close(encoded)
147+ return err
148+ }).Once()
149+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ io.Reader) error {
150+ <-ctx.Done()
151+ return ctx.Err()
152+ }).Once()
153+ ctx, cancel := context.WithCancel(context.Background())
154+ defer cancel()
155+ done := make(chan error, 1)
156+ go func() { done <- NewWriter(codec, clip).Save(ctx, frame, "") }()
157+ select {
158+ case <-started:
159+ case <-time.After(time.Second):
160+ t.Fatal("encoder did not start")
161+ }
162+ cancel()
163+ select {
164+ case err := <-done:
165+ require.ErrorIs(t, err, context.Canceled)
166+ case <-time.After(time.Second):
167+ t.Fatal("clipboard cancellation did not terminate encoding")
168+ }
169+ select {
170+ case <-encoded:
171+ default:
172+ t.Fatal("Save returned before the encoder released its borrowed frame")
173+ }
174+}
175+
176+func TestStderrBound(t *testing.T) {
177+ b := &limitedError{}
178+ n, err := b.Write(make([]byte, 1<<20))
179+ require.NoError(t, err)
180+ require.Equal(t, 1<<20, n)
181+ require.Len(t, b.data, 4096)
182+}
183+
184+func BenchmarkClipboardEncode(b *testing.B) {
185+ f := ports.Frame{Pixels: make([]byte, 1920*1080*4), Width: 1920, Height: 1080, Stride: 1920 * 4, Format: ports.XRGB8888}
186+ b.ReportAllocs()
187+ for b.Loop() {
188+ if err := png.New().Encode(context.Background(), io.Discard, f); err != nil {
189+ b.Fatal(err)
190+ }
191+ }
192+}
@@ -0,0 +1,192 @@
1+package clipboard
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/nefercap/internal/adapters/png"
14+ mocks "github.com/bnema/nefercap/internal/mocks/ports"
15+ "github.com/bnema/nefercap/internal/ports"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+var frame = ports.Frame{Pixels: []byte{1, 2, 3, 0}, Width: 1, Height: 1, Stride: 4, Format: ports.XRGB8888}
21+
22+func TestWriterDestinations(t *testing.T) {
23+ for _, both := range []bool{false, true} {
24+ t.Run(map[bool]string{false: "clipboard only", true: "file and clipboard"}[both], func(t *testing.T) {
25+ dir := t.TempDir()
26+ path := ""
27+ if both {
28+ path = filepath.Join(dir, "shot.png")
29+ }
30+ clip := mocks.NewMockClipboard(t)
31+ var data []byte
32+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error {
33+ var err error
34+ data, err = io.ReadAll(r)
35+ return err
36+ }).Once()
37+ w := NewWriter(png.New(), clip)
38+ require.NoError(t, w.Save(context.Background(), frame, path))
39+ require.Equal(t, both, w.Saved)
40+ require.True(t, strings.HasPrefix(string(data), "\x89PNG"))
41+ if both {
42+ disk, err := os.ReadFile(path)
43+ require.NoError(t, err)
44+ require.Equal(t, disk, data)
45+ } else {
46+ files, err := os.ReadDir(dir)
47+ require.NoError(t, err)
48+ require.Empty(t, files)
49+ }
50+ })
51+ }
52+}
53+
54+func TestSavedFileSurvivesClipboardFailureAndReplacement(t *testing.T) {
55+ path := filepath.Join(t.TempDir(), "shot.png")
56+ clip := mocks.NewMockClipboard(t)
57+ failure := errors.New("clipboard unavailable")
58+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error {
59+ require.NoError(t, os.Rename(path, path+".saved"))
60+ require.NoError(t, os.Symlink(path+".replacement", path))
61+ data, err := io.ReadAll(r)
62+ require.NoError(t, err)
63+ require.True(t, strings.HasPrefix(string(data), "\x89PNG"))
64+ return failure
65+ }).Once()
66+ w := NewWriter(png.New(), clip)
67+ require.ErrorIs(t, w.Save(context.Background(), frame, path), failure)
68+ require.True(t, w.Saved)
69+ disk, err := os.ReadFile(path + ".saved")
70+ require.NoError(t, err)
71+ require.True(t, strings.HasPrefix(string(disk), "\x89PNG"))
72+}
73+
74+func TestWriterRejectsBeforeClipboard(t *testing.T) {
75+ clip := mocks.NewMockClipboard(t)
76+ w := NewWriter(png.New(), clip)
77+ ctx, cancel := context.WithCancel(context.Background())
78+ cancel()
79+ path := filepath.Join(t.TempDir(), "shot.png")
80+ require.ErrorIs(t, w.Save(ctx, frame, path), context.Canceled)
81+ require.Error(t, w.Save(context.Background(), ports.Frame{}, path))
82+ require.NoFileExists(t, path)
83+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0600))
84+ require.ErrorIs(t, w.Save(context.Background(), frame, path), ports.ErrPathExists)
85+ disk, err := os.ReadFile(path)
86+ require.NoError(t, err)
87+ require.Equal(t, "keep", string(disk))
88+}
89+
90+func TestEncodeFailureAndEarlyClipboardExit(t *testing.T) {
91+ for _, path := range []string{"", filepath.Join(t.TempDir(), "failed.png")} {
92+ codec := mocks.NewMockScreenshotWriter(t)
93+ clip := mocks.NewMockClipboard(t)
94+ failure := errors.New("encode failed")
95+ codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).Return(failure).Once()
96+ if path == "" {
97+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }).Once()
98+ }
99+ w := NewWriter(codec, clip)
100+ require.ErrorIs(t, w.Save(context.Background(), frame, path), failure)
101+ require.False(t, w.Saved)
102+ if path != "" {
103+ require.NoFileExists(t, path)
104+ }
105+ }
106+ clip := mocks.NewMockClipboard(t)
107+ failure := errors.New("early exit")
108+ clip.EXPECT().Copy(mock.Anything, mock.Anything).Return(failure).Once()
109+ require.ErrorIs(t, NewWriter(png.New(), clip).Save(context.Background(), frame, ""), failure)
110+}
111+
112+// Real executable fixtures exercise os/exec without a handwritten process fake.
113+func TestNativeProcessFailures(t *testing.T) {
114+ dir := t.TempDir()
115+ t.Setenv("PATH", dir)
116+ require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "install wl-clipboard")
117+ executable := filepath.Join(dir, "wl-copy")
118+ require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nprintf 'no compositor' >&2\nexit 7\n"), 0700))
119+ require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "no compositor")
120+ require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nexec /bin/sleep 10\n"), 0700))
121+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
122+ defer cancel()
123+ began := time.Now()
124+ require.ErrorIs(t, New().Copy(ctx, strings.NewReader("PNG")), context.DeadlineExceeded)
125+ require.Less(t, time.Since(began), time.Second)
126+}
127+
128+func TestNativeSuccessfulForkDoesNotWaitForStderrOwner(t *testing.T) {
129+ dir := t.TempDir()
130+ t.Setenv("PATH", dir)
131+ // This is a real child process, retaining only stderr after handoff.
132+ executable := filepath.Join(dir, "wl-copy")
133+ require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\n/bin/cat >/dev/null\n/bin/sleep 1 >/dev/null 2>&2 &\nexit 0\n"), 0o700))
134+ began := time.Now()
135+ require.NoError(t, New().Copy(context.Background(), strings.NewReader("PNG")))
136+ require.Less(t, time.Since(began), 500*time.Millisecond)
137+}
138+
139+func TestCopyCancellationJoinsEncoder(t *testing.T) {
140+ codec := mocks.NewMockScreenshotWriter(t)
141+ clip := mocks.NewMockClipboard(t)
142+ started, encoded := make(chan struct{}), make(chan struct{})
143+ codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).RunAndReturn(func(_ context.Context, out io.Writer, _ ports.Frame) error {
144+ close(started)
145+ _, err := out.Write([]byte("PNG")) // backpressure until the reader is closed
146+ close(encoded)
147+ return err
148+ }).Once()
149+ clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ io.Reader) error {
150+ <-ctx.Done()
151+ return ctx.Err()
152+ }).Once()
153+ ctx, cancel := context.WithCancel(context.Background())
154+ defer cancel()
155+ done := make(chan error, 1)
156+ go func() { done <- NewWriter(codec, clip).Save(ctx, frame, "") }()
157+ select {
158+ case <-started:
159+ case <-time.After(time.Second):
160+ t.Fatal("encoder did not start")
161+ }
162+ cancel()
163+ select {
164+ case err := <-done:
165+ require.ErrorIs(t, err, context.Canceled)
166+ case <-time.After(time.Second):
167+ t.Fatal("clipboard cancellation did not terminate encoding")
168+ }
169+ select {
170+ case <-encoded:
171+ default:
172+ t.Fatal("Save returned before the encoder released its borrowed frame")
173+ }
174+}
175+
176+func TestStderrBound(t *testing.T) {
177+ b := &limitedError{}
178+ n, err := b.Write(make([]byte, 1<<20))
179+ require.NoError(t, err)
180+ require.Equal(t, 1<<20, n)
181+ require.Len(t, b.data, 4096)
182+}
183+
184+func BenchmarkClipboardEncode(b *testing.B) {
185+ f := ports.Frame{Pixels: make([]byte, 1920*1080*4), Width: 1920, Height: 1080, Stride: 1920 * 4, Format: ports.XRGB8888}
186+ b.ReportAllocs()
187+ for b.Loop() {
188+ if err := png.New().Encode(context.Background(), io.Discard, f); err != nil {
189+ b.Fatal(err)
190+ }
191+ }
192+}
M internal/adapters/png/png.go +8 -2
@@ -59,13 +59,19 @@ func (*Writer) Save(ctx context.Context, f ports.Frame, path string) (err error)
5959 }
6060 }
6161 }()
62- return encode(ctx, file, f)
62+ return New().Encode(ctx, file, f)
6363 }
6464
6565 // encode writes f to w. The frame is converted once to opaque RGBA because the
6666 // encoder only has fast paths for the standard image types and stored pixels
6767 // are BGRA; the copy is exactly one frame and is released on return.
68-func encode(ctx context.Context, w io.Writer, f ports.Frame) error {
68+func (*Writer) Encode(ctx context.Context, w io.Writer, f ports.Frame) error {
69+ if err := ctx.Err(); err != nil {
70+ return err
71+ }
72+ if err := f.Validate(); err != nil {
73+ return err
74+ }
6975 img, err := toRGBA(ctx, f)
7076 if err != nil {
7177 return err
@@ -59,13 +59,19 @@ func (*Writer) Save(ctx context.Context, f ports.Frame, path string) (err error)
59 }59 }
60 }60 }
61 }()61 }()
62- return encode(ctx, file, f)62+ return New().Encode(ctx, file, f)
63 }63 }
64 64
65 // encode writes f to w. The frame is converted once to opaque RGBA because the65 // encode writes f to w. The frame is converted once to opaque RGBA because the
66 // encoder only has fast paths for the standard image types and stored pixels66 // encoder only has fast paths for the standard image types and stored pixels
67 // are BGRA; the copy is exactly one frame and is released on return.67 // are BGRA; the copy is exactly one frame and is released on return.
68-func encode(ctx context.Context, w io.Writer, f ports.Frame) error {68+func (*Writer) Encode(ctx context.Context, w io.Writer, f ports.Frame) error {
69+ if err := ctx.Err(); err != nil {
70+ return err
71+ }
72+ if err := f.Validate(); err != nil {
73+ return err
74+ }
69 img, err := toRGBA(ctx, f)75 img, err := toRGBA(ctx, f)
70 if err != nil {76 if err != nil {
71 return err77 return err
M internal/app/app.go +13 -3
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/nefercap/internal/adapters/cli"
12+ "github.com/bnema/nefercap/internal/adapters/clipboard"
1213 "github.com/bnema/nefercap/internal/adapters/ffmpeg"
1314 "github.com/bnema/nefercap/internal/adapters/png"
1415 "github.com/bnema/nefercap/internal/adapters/wayland"
@@ -63,7 +64,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
6364 if err != nil {
6465 return err
6566 }
66- selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Video: options.Video, Duration: options.Duration}
67+ selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration}
6768 switch options.Command {
6869 case cli.Screenshot:
6970 selection.Mode = ports.Screenshot
@@ -88,8 +89,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
8889 }
8990 selection.Target = state.Target
9091 }
91- err = core.New(source, png.New(), ffmpeg.New()).Run(ctx, selection)
92- saved = err == nil
92+ var writer ports.ScreenshotWriter = png.New()
93+ var copied *clipboard.Writer
94+ if selection.Clipboard {
95+ copied = clipboard.NewWriter(writer, clipboard.New())
96+ writer = copied
97+ }
98+ err = core.New(source, writer, ffmpeg.New()).Run(ctx, selection)
99+ saved = err == nil && selection.Path != ""
100+ if copied != nil {
101+ saved = copied.Saved
102+ }
93103 }
94104 if saved && err == nil {
95105 log.Info().Msg("capture complete")
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 "github.com/bnema/nefercap/internal/adapters/cli"11 "github.com/bnema/nefercap/internal/adapters/cli"
12+ "github.com/bnema/nefercap/internal/adapters/clipboard"
12 "github.com/bnema/nefercap/internal/adapters/ffmpeg"13 "github.com/bnema/nefercap/internal/adapters/ffmpeg"
13 "github.com/bnema/nefercap/internal/adapters/png"14 "github.com/bnema/nefercap/internal/adapters/png"
14 "github.com/bnema/nefercap/internal/adapters/wayland"15 "github.com/bnema/nefercap/internal/adapters/wayland"
@@ -63,7 +64,7 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
63 if err != nil {64 if err != nil {
64 return err65 return err
65 }66 }
66- selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Video: options.Video, Duration: options.Duration}67+ selection = ports.Selection{Target: ports.Target{OutputID: target.ID, Region: options.Region}, Path: options.Path, Clipboard: options.Clipboard, Video: options.Video, Duration: options.Duration}
67 switch options.Command {68 switch options.Command {
68 case cli.Screenshot:69 case cli.Screenshot:
69 selection.Mode = ports.Screenshot70 selection.Mode = ports.Screenshot
@@ -88,8 +89,17 @@ func Run(ctx context.Context, options cli.Options, output io.Writer) (err error)
88 }89 }
89 selection.Target = state.Target90 selection.Target = state.Target
90 }91 }
91- err = core.New(source, png.New(), ffmpeg.New()).Run(ctx, selection)92+ var writer ports.ScreenshotWriter = png.New()
92- saved = err == nil93+ var copied *clipboard.Writer
94+ if selection.Clipboard {
95+ copied = clipboard.NewWriter(writer, clipboard.New())
96+ writer = copied
97+ }
98+ err = core.New(source, writer, ffmpeg.New()).Run(ctx, selection)
99+ saved = err == nil && selection.Path != ""
100+ if copied != nil {
101+ saved = copied.Saved
102+ }
93 }103 }
94 if saved && err == nil {104 if saved && err == nil {
95 log.Info().Msg("capture complete")105 log.Info().Msg("capture complete")
A internal/app/clipboard_test.go +18 -0
@@ -0,0 +1,18 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "github.com/stretchr/testify/require"
7+ "testing"
8+)
9+
10+func TestClipboardSavedPathOnFailure(t *testing.T) {
11+ failure := errors.New("wl-copy failed")
12+ var out bytes.Buffer
13+ require.ErrorIs(t, finishCapture(&out, "shot.png", true, failure), failure)
14+ require.Equal(t, "shot.png\n", out.String())
15+ out.Reset()
16+ require.ErrorIs(t, finishCapture(&out, "", false, failure), failure)
17+ require.Empty(t, out.String())
18+}
@@ -0,0 +1,18 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "github.com/stretchr/testify/require"
7+ "testing"
8+)
9+
10+func TestClipboardSavedPathOnFailure(t *testing.T) {
11+ failure := errors.New("wl-copy failed")
12+ var out bytes.Buffer
13+ require.ErrorIs(t, finishCapture(&out, "shot.png", true, failure), failure)
14+ require.Equal(t, "shot.png\n", out.String())
15+ out.Reset()
16+ require.ErrorIs(t, finishCapture(&out, "", false, failure), failure)
17+ require.Empty(t, out.String())
18+}
M internal/app/interactive.go +2 -1
@@ -46,7 +46,8 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
4646 }
4747 sel.Duration = options.Duration
4848 sel.Path = options.Path
49- if sel.Path == "" {
49+ sel.Clipboard = options.Clipboard
50+ if sel.Path == "" && !sel.Clipboard {
5051 if mode == ports.Record {
5152 sel.Path, err = destination.Video()
5253 } else {
@@ -46,7 +46,8 @@ func chooseInteractive(ctx context.Context, source *wayland.Source, outputs []po
46 }46 }
47 sel.Duration = options.Duration47 sel.Duration = options.Duration
48 sel.Path = options.Path48 sel.Path = options.Path
49- if sel.Path == "" {49+ sel.Clipboard = options.Clipboard
50+ if sel.Path == "" && !sel.Clipboard {
50 if mode == ports.Record {51 if mode == ports.Record {
51 sel.Path, err = destination.Video()52 sel.Path, err = destination.Video()
52 } else {53 } else {
A internal/core/clipboard_test.go +30 -0
@@ -0,0 +1,30 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "github.com/bnema/nefercap/internal/core"
6+ "github.com/stretchr/testify/mock"
7+ "github.com/stretchr/testify/require"
8+ "testing"
9+)
10+
11+func TestClipboardSelectionValidation(t *testing.T) {
12+ h := newHarness(t)
13+ sel := shot()
14+ sel.Clipboard = true
15+ sel.Path = ""
16+ h.src.EXPECT().Capture(mock.Anything, sel.Target).Return(frame, nil).Once()
17+ h.png.EXPECT().Save(mock.Anything, frame, "").Return(nil).Once()
18+ require.NoError(t, h.svc.Run(context.Background(), sel))
19+ h = newHarness(t)
20+ sel = rec(0)
21+ sel.Clipboard = true
22+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
23+ h = newHarness(t)
24+ sel = shot()
25+ sel.Clipboard = true
26+ sel.Path = ""
27+ ctx, cancel := context.WithCancel(context.Background())
28+ cancel()
29+ require.ErrorIs(t, h.svc.Run(ctx, sel), context.Canceled)
30+}
@@ -0,0 +1,30 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "github.com/bnema/nefercap/internal/core"
6+ "github.com/stretchr/testify/mock"
7+ "github.com/stretchr/testify/require"
8+ "testing"
9+)
10+
11+func TestClipboardSelectionValidation(t *testing.T) {
12+ h := newHarness(t)
13+ sel := shot()
14+ sel.Clipboard = true
15+ sel.Path = ""
16+ h.src.EXPECT().Capture(mock.Anything, sel.Target).Return(frame, nil).Once()
17+ h.png.EXPECT().Save(mock.Anything, frame, "").Return(nil).Once()
18+ require.NoError(t, h.svc.Run(context.Background(), sel))
19+ h = newHarness(t)
20+ sel = rec(0)
21+ sel.Clipboard = true
22+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
23+ h = newHarness(t)
24+ sel = shot()
25+ sel.Clipboard = true
26+ sel.Path = ""
27+ ctx, cancel := context.WithCancel(context.Background())
28+ cancel()
29+ require.ErrorIs(t, h.svc.Run(ctx, sel), context.Canceled)
30+}
M internal/core/service.go +4 -1
@@ -286,7 +286,10 @@ func validateSelection(sel ports.Selection) error {
286286 if err := validateRegion(sel.Target.Region); err != nil {
287287 return err
288288 }
289- if sel.Path == "" {
289+ if sel.Clipboard && sel.Mode != ports.Screenshot {
290+ return fmt.Errorf("%w: clipboard requires screenshot", ErrInvalidSelection)
291+ }
292+ if sel.Path == "" && !sel.Clipboard {
290293 return fmt.Errorf("%w: empty path", ErrInvalidSelection)
291294 }
292295 if sel.Duration < 0 {
@@ -286,7 +286,10 @@ func validateSelection(sel ports.Selection) error {
286 if err := validateRegion(sel.Target.Region); err != nil {286 if err := validateRegion(sel.Target.Region); err != nil {
287 return err287 return err
288 }288 }
289- if sel.Path == "" {289+ if sel.Clipboard && sel.Mode != ports.Screenshot {
290+ return fmt.Errorf("%w: clipboard requires screenshot", ErrInvalidSelection)
291+ }
292+ if sel.Path == "" && !sel.Clipboard {
290 return fmt.Errorf("%w: empty path", ErrInvalidSelection)293 return fmt.Errorf("%w: empty path", ErrInvalidSelection)
291 }294 }
292 if sel.Duration < 0 {295 if sel.Duration < 0 {
M internal/mocks/ports/capture_mock.go +157 -0
@@ -6,6 +6,7 @@ package portsmocks
66
77 import (
88 "context"
9+ "io"
910
1011 "github.com/bnema/nefercap/internal/ports"
1112 mock "github.com/stretchr/testify/mock"
@@ -255,6 +256,69 @@ func (_m *MockScreenshotWriter) EXPECT() *MockScreenshotWriter_Expecter {
255256 return &MockScreenshotWriter_Expecter{mock: &_m.Mock}
256257 }
257258
259+// Encode provides a mock function for the type MockScreenshotWriter
260+func (_mock *MockScreenshotWriter) Encode(context1 context.Context, writer io.Writer, frame ports.Frame) error {
261+ ret := _mock.Called(context1, writer, frame)
262+
263+ if len(ret) == 0 {
264+ panic("no return value specified for Encode")
265+ }
266+
267+ var r0 error
268+ if returnFunc, ok := ret.Get(0).(func(context.Context, io.Writer, ports.Frame) error); ok {
269+ r0 = returnFunc(context1, writer, frame)
270+ } else {
271+ r0 = ret.Error(0)
272+ }
273+ return r0
274+}
275+
276+// MockScreenshotWriter_Encode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encode'
277+type MockScreenshotWriter_Encode_Call struct {
278+ *mock.Call
279+}
280+
281+// Encode is a helper method to define mock.On call
282+// - context1 context.Context
283+// - writer io.Writer
284+// - frame ports.Frame
285+func (_e *MockScreenshotWriter_Expecter) Encode(context1 any, writer any, frame any) *MockScreenshotWriter_Encode_Call {
286+ return &MockScreenshotWriter_Encode_Call{Call: _e.mock.On("Encode", context1, writer, frame)}
287+}
288+
289+func (_c *MockScreenshotWriter_Encode_Call) Run(run func(context1 context.Context, writer io.Writer, frame ports.Frame)) *MockScreenshotWriter_Encode_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ var arg0 context.Context
292+ if args[0] != nil {
293+ arg0 = args[0].(context.Context)
294+ }
295+ var arg1 io.Writer
296+ if args[1] != nil {
297+ arg1 = args[1].(io.Writer)
298+ }
299+ var arg2 ports.Frame
300+ if args[2] != nil {
301+ arg2 = args[2].(ports.Frame)
302+ }
303+ run(
304+ arg0,
305+ arg1,
306+ arg2,
307+ )
308+ })
309+ return _c
310+}
311+
312+func (_c *MockScreenshotWriter_Encode_Call) Return(err error) *MockScreenshotWriter_Encode_Call {
313+ _c.Call.Return(err)
314+ return _c
315+}
316+
317+func (_c *MockScreenshotWriter_Encode_Call) RunAndReturn(run func(context1 context.Context, writer io.Writer, frame ports.Frame) error) *MockScreenshotWriter_Encode_Call {
318+ _c.Call.Return(run)
319+ return _c
320+}
321+
258322 // Save provides a mock function for the type MockScreenshotWriter
259323 func (_mock *MockScreenshotWriter) Save(context1 context.Context, frame ports.Frame, s string) error {
260324 ret := _mock.Called(context1, frame, s)
@@ -318,6 +382,99 @@ func (_c *MockScreenshotWriter_Save_Call) RunAndReturn(run func(context1 context
318382 return _c
319383 }
320384
385+// NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
386+// The first argument is typically a *testing.T value.
387+func NewMockClipboard(t interface {
388+ mock.TestingT
389+ Cleanup(func())
390+}) *MockClipboard {
391+ if helper, ok := t.(interface{ Helper() }); ok {
392+ helper.Helper()
393+ }
394+
395+ mock := &MockClipboard{}
396+ mock.Mock.Test(t)
397+
398+ t.Cleanup(func() {
399+ if helper, ok := t.(interface{ Helper() }); ok {
400+ helper.Helper()
401+ }
402+ mock.AssertExpectations(t)
403+ })
404+
405+ return mock
406+}
407+
408+// MockClipboard is an autogenerated mock type for the Clipboard type
409+type MockClipboard struct {
410+ mock.Mock
411+}
412+
413+type MockClipboard_Expecter struct {
414+ mock *mock.Mock
415+}
416+
417+func (_m *MockClipboard) EXPECT() *MockClipboard_Expecter {
418+ return &MockClipboard_Expecter{mock: &_m.Mock}
419+}
420+
421+// Copy provides a mock function for the type MockClipboard
422+func (_mock *MockClipboard) Copy(context1 context.Context, reader io.Reader) error {
423+ ret := _mock.Called(context1, reader)
424+
425+ if len(ret) == 0 {
426+ panic("no return value specified for Copy")
427+ }
428+
429+ var r0 error
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, io.Reader) error); ok {
431+ r0 = returnFunc(context1, reader)
432+ } else {
433+ r0 = ret.Error(0)
434+ }
435+ return r0
436+}
437+
438+// MockClipboard_Copy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Copy'
439+type MockClipboard_Copy_Call struct {
440+ *mock.Call
441+}
442+
443+// Copy is a helper method to define mock.On call
444+// - context1 context.Context
445+// - reader io.Reader
446+func (_e *MockClipboard_Expecter) Copy(context1 any, reader any) *MockClipboard_Copy_Call {
447+ return &MockClipboard_Copy_Call{Call: _e.mock.On("Copy", context1, reader)}
448+}
449+
450+func (_c *MockClipboard_Copy_Call) Run(run func(context1 context.Context, reader io.Reader)) *MockClipboard_Copy_Call {
451+ _c.Call.Run(func(args mock.Arguments) {
452+ var arg0 context.Context
453+ if args[0] != nil {
454+ arg0 = args[0].(context.Context)
455+ }
456+ var arg1 io.Reader
457+ if args[1] != nil {
458+ arg1 = args[1].(io.Reader)
459+ }
460+ run(
461+ arg0,
462+ arg1,
463+ )
464+ })
465+ return _c
466+}
467+
468+func (_c *MockClipboard_Copy_Call) Return(err error) *MockClipboard_Copy_Call {
469+ _c.Call.Return(err)
470+ return _c
471+}
472+
473+func (_c *MockClipboard_Copy_Call) RunAndReturn(run func(context1 context.Context, reader io.Reader) error) *MockClipboard_Copy_Call {
474+ _c.Call.Return(run)
475+ return _c
476+}
477+
321478 // NewMockVideoWriter creates a new instance of MockVideoWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
322479 // The first argument is typically a *testing.T value.
323480 func NewMockVideoWriter(t interface {
@@ -6,6 +6,7 @@ package portsmocks
6 6
7 import (7 import (
8 "context"8 "context"
9+ "io"
9 10
10 "github.com/bnema/nefercap/internal/ports"11 "github.com/bnema/nefercap/internal/ports"
11 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
@@ -255,6 +256,69 @@ func (_m *MockScreenshotWriter) EXPECT() *MockScreenshotWriter_Expecter {
255 return &MockScreenshotWriter_Expecter{mock: &_m.Mock}256 return &MockScreenshotWriter_Expecter{mock: &_m.Mock}
256 }257 }
257 258
259+// Encode provides a mock function for the type MockScreenshotWriter
260+func (_mock *MockScreenshotWriter) Encode(context1 context.Context, writer io.Writer, frame ports.Frame) error {
261+ ret := _mock.Called(context1, writer, frame)
262+
263+ if len(ret) == 0 {
264+ panic("no return value specified for Encode")
265+ }
266+
267+ var r0 error
268+ if returnFunc, ok := ret.Get(0).(func(context.Context, io.Writer, ports.Frame) error); ok {
269+ r0 = returnFunc(context1, writer, frame)
270+ } else {
271+ r0 = ret.Error(0)
272+ }
273+ return r0
274+}
275+
276+// MockScreenshotWriter_Encode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encode'
277+type MockScreenshotWriter_Encode_Call struct {
278+ *mock.Call
279+}
280+
281+// Encode is a helper method to define mock.On call
282+// - context1 context.Context
283+// - writer io.Writer
284+// - frame ports.Frame
285+func (_e *MockScreenshotWriter_Expecter) Encode(context1 any, writer any, frame any) *MockScreenshotWriter_Encode_Call {
286+ return &MockScreenshotWriter_Encode_Call{Call: _e.mock.On("Encode", context1, writer, frame)}
287+}
288+
289+func (_c *MockScreenshotWriter_Encode_Call) Run(run func(context1 context.Context, writer io.Writer, frame ports.Frame)) *MockScreenshotWriter_Encode_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ var arg0 context.Context
292+ if args[0] != nil {
293+ arg0 = args[0].(context.Context)
294+ }
295+ var arg1 io.Writer
296+ if args[1] != nil {
297+ arg1 = args[1].(io.Writer)
298+ }
299+ var arg2 ports.Frame
300+ if args[2] != nil {
301+ arg2 = args[2].(ports.Frame)
302+ }
303+ run(
304+ arg0,
305+ arg1,
306+ arg2,
307+ )
308+ })
309+ return _c
310+}
311+
312+func (_c *MockScreenshotWriter_Encode_Call) Return(err error) *MockScreenshotWriter_Encode_Call {
313+ _c.Call.Return(err)
314+ return _c
315+}
316+
317+func (_c *MockScreenshotWriter_Encode_Call) RunAndReturn(run func(context1 context.Context, writer io.Writer, frame ports.Frame) error) *MockScreenshotWriter_Encode_Call {
318+ _c.Call.Return(run)
319+ return _c
320+}
321+
258 // Save provides a mock function for the type MockScreenshotWriter322 // Save provides a mock function for the type MockScreenshotWriter
259 func (_mock *MockScreenshotWriter) Save(context1 context.Context, frame ports.Frame, s string) error {323 func (_mock *MockScreenshotWriter) Save(context1 context.Context, frame ports.Frame, s string) error {
260 ret := _mock.Called(context1, frame, s)324 ret := _mock.Called(context1, frame, s)
@@ -318,6 +382,99 @@ func (_c *MockScreenshotWriter_Save_Call) RunAndReturn(run func(context1 context
318 return _c382 return _c
319 }383 }
320 384
385+// NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
386+// The first argument is typically a *testing.T value.
387+func NewMockClipboard(t interface {
388+ mock.TestingT
389+ Cleanup(func())
390+}) *MockClipboard {
391+ if helper, ok := t.(interface{ Helper() }); ok {
392+ helper.Helper()
393+ }
394+
395+ mock := &MockClipboard{}
396+ mock.Mock.Test(t)
397+
398+ t.Cleanup(func() {
399+ if helper, ok := t.(interface{ Helper() }); ok {
400+ helper.Helper()
401+ }
402+ mock.AssertExpectations(t)
403+ })
404+
405+ return mock
406+}
407+
408+// MockClipboard is an autogenerated mock type for the Clipboard type
409+type MockClipboard struct {
410+ mock.Mock
411+}
412+
413+type MockClipboard_Expecter struct {
414+ mock *mock.Mock
415+}
416+
417+func (_m *MockClipboard) EXPECT() *MockClipboard_Expecter {
418+ return &MockClipboard_Expecter{mock: &_m.Mock}
419+}
420+
421+// Copy provides a mock function for the type MockClipboard
422+func (_mock *MockClipboard) Copy(context1 context.Context, reader io.Reader) error {
423+ ret := _mock.Called(context1, reader)
424+
425+ if len(ret) == 0 {
426+ panic("no return value specified for Copy")
427+ }
428+
429+ var r0 error
430+ if returnFunc, ok := ret.Get(0).(func(context.Context, io.Reader) error); ok {
431+ r0 = returnFunc(context1, reader)
432+ } else {
433+ r0 = ret.Error(0)
434+ }
435+ return r0
436+}
437+
438+// MockClipboard_Copy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Copy'
439+type MockClipboard_Copy_Call struct {
440+ *mock.Call
441+}
442+
443+// Copy is a helper method to define mock.On call
444+// - context1 context.Context
445+// - reader io.Reader
446+func (_e *MockClipboard_Expecter) Copy(context1 any, reader any) *MockClipboard_Copy_Call {
447+ return &MockClipboard_Copy_Call{Call: _e.mock.On("Copy", context1, reader)}
448+}
449+
450+func (_c *MockClipboard_Copy_Call) Run(run func(context1 context.Context, reader io.Reader)) *MockClipboard_Copy_Call {
451+ _c.Call.Run(func(args mock.Arguments) {
452+ var arg0 context.Context
453+ if args[0] != nil {
454+ arg0 = args[0].(context.Context)
455+ }
456+ var arg1 io.Reader
457+ if args[1] != nil {
458+ arg1 = args[1].(io.Reader)
459+ }
460+ run(
461+ arg0,
462+ arg1,
463+ )
464+ })
465+ return _c
466+}
467+
468+func (_c *MockClipboard_Copy_Call) Return(err error) *MockClipboard_Copy_Call {
469+ _c.Call.Return(err)
470+ return _c
471+}
472+
473+func (_c *MockClipboard_Copy_Call) RunAndReturn(run func(context1 context.Context, reader io.Reader) error) *MockClipboard_Copy_Call {
474+ _c.Call.Return(run)
475+ return _c
476+}
477+
321 // NewMockVideoWriter creates a new instance of MockVideoWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.478 // NewMockVideoWriter creates a new instance of MockVideoWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
322 // The first argument is typically a *testing.T value.479 // The first argument is typically a *testing.T value.
323 func NewMockVideoWriter(t interface {480 func NewMockVideoWriter(t interface {
M internal/ports/capture.go +14 -5
@@ -3,6 +3,7 @@ package ports
33
44 import (
55 "context"
6+ "io"
67 "time"
78 )
89
@@ -67,6 +68,13 @@ type Source interface {
6768 // Save completes all reads from borrowed frame storage before returning.
6869 type ScreenshotWriter interface {
6970 Save(context.Context, Frame, string) error
71+ // Encode consumes borrowed pixels synchronously without creating a file.
72+ Encode(context.Context, io.Writer, Frame) error
73+}
74+
75+// Clipboard accepts PNG bytes synchronously. Its owner may outlive this process.
76+type Clipboard interface {
77+ Copy(context.Context, io.Reader) error
7078 }
7179
7280 // VideoSettings describes fixed-rate silent SDR video. Zero output dimensions
@@ -107,11 +115,12 @@ const (
107115
108116 // Selection is returned by a control panel before capture begins.
109117 type Selection struct {
110- Mode Mode
111- Target Target
112- Path string
113- Video VideoSettings
114- Duration time.Duration // zero records until context cancellation
118+ Mode Mode
119+ Target Target
120+ Path string
121+ Clipboard bool // screenshot only; permits an empty Path
122+ Video VideoSettings
123+ Duration time.Duration // zero records until context cancellation
115124 }
116125
117126 // Selector must close its surface before returning an accepted selection.
@@ -3,6 +3,7 @@ package ports
3 3
4 import (4 import (
5 "context"5 "context"
6+ "io"
6 "time"7 "time"
7 )8 )
8 9
@@ -67,6 +68,13 @@ type Source interface {
67 // Save completes all reads from borrowed frame storage before returning.68 // Save completes all reads from borrowed frame storage before returning.
68 type ScreenshotWriter interface {69 type ScreenshotWriter interface {
69 Save(context.Context, Frame, string) error70 Save(context.Context, Frame, string) error
71+ // Encode consumes borrowed pixels synchronously without creating a file.
72+ Encode(context.Context, io.Writer, Frame) error
73+}
74+
75+// Clipboard accepts PNG bytes synchronously. Its owner may outlive this process.
76+type Clipboard interface {
77+ Copy(context.Context, io.Reader) error
70 }78 }
71 79
72 // VideoSettings describes fixed-rate silent SDR video. Zero output dimensions80 // VideoSettings describes fixed-rate silent SDR video. Zero output dimensions
@@ -107,11 +115,12 @@ const (
107 115
108 // Selection is returned by a control panel before capture begins.116 // Selection is returned by a control panel before capture begins.
109 type Selection struct {117 type Selection struct {
110- Mode Mode118+ Mode Mode
111- Target Target119+ Target Target
112- Path string120+ Path string
113- Video VideoSettings121+ Clipboard bool // screenshot only; permits an empty Path
114- Duration time.Duration // zero records until context cancellation122+ Video VideoSettings
123+ Duration time.Duration // zero records until context cancellation
115 }124 }
116 125
117 // Selector must close its surface before returning an accepted selection.126 // Selector must close its surface before returning an accepted selection.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close