1// Package cli parses the command line into capture options.
2package cli
3
4import (
5 "errors"
6 "flag"
7 "fmt"
8 "io"
9 "math"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/bnema/nefercap/internal/ports"
15)
16
17// Command is the selected subcommand.
18type Command string
19
20const (
21 // AllInOne is the command-less invocation: it stops an active recording,
22 // otherwise opens the selector where Tab switches between shot and rec.
23 // It is never named on the command line and takes no capture flags.
24 AllInOne Command = "all-in-one"
25 // Shot and Rec pick a target with the layer-shell selector; Rec toggles:
26 // while a recording is active it stops that recording instead. Their mode
27 // is fixed: no Tab toggle.
28 Shot Command = "shot"
29 Rec Command = "rec"
30 Stop Command = "stop"
31 Status Command = "status"
32 // Outputs, Screenshot and Record never open the selector. Outputs needs
33 // no -file; Screenshot requires it unless clipboard-only, Record always.
34 Outputs Command = "outputs"
35 Screenshot Command = "screenshot"
36 Record Command = "record"
37)
38
39// DefaultFPS is the recording frame rate when -fps is not given.
40const DefaultFPS = 30
41
42// ErrUsage wraps every command-line usage error.
43var ErrUsage = errors.New("usage error")
44
45// Options is the parsed command line. Video is set only for Record and Rec.
46// Path is optional for Shot and Rec, where an empty Path lets the caller
47// choose one. Clipboard with an empty Path means clipboard only.
48type Options struct {
49 Command Command
50 Output string // output name; empty lets the caller choose
51 Region ports.Region
52 Path string
53 Video ports.VideoSettings
54 Duration time.Duration
55 Debug bool
56 Clipboard bool
57}
58
59// Usage describes the command line.
60func Usage() string {
61 return fmt.Sprintf(`Usage: nefercap [command] [flags]
62
63 (no command) all-in-one: stop an active recording, else open the selector
64 in shot mode; Tab switches shot <-> rec. Takes no flags but
65 -debug; settings come from the config file.
66
67Interactive commands (layer-shell selector over the connected outputs):
68 shot select what to capture and save a PNG; fixed mode
69 rec select what to record; run again while recording to stop it
70 stop stop the active recording
71 status print the recording state
72
73Selector keys and mouse:
74 drag select a region, accepted when the left button is released
75 click capture the whole monitor; after w, the current workspace
76 r back to region selection
77 m choose the whole monitor, Enter confirms
78 w choose the current workspace when known, click or Enter confirms
79 1..9 pick that whole monitor from any overlay
80 g toggle full-monitor center and thirds guides (on by default)
81 Tab switch shot <-> rec (all-in-one only)
82 Escape cancel
83
84Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config)
85 selector.grid = off hide guides on startup; G still toggles them
86 selector.freeze = off screenshot selector shows the live screen (default on: still copy)
87 screenshot.dir = ~/Pictures/Shots absolute or ~/ path; default XDG Pictures/Screenshots
88 screenshot.output = file+clipboard file (default), file+clipboard or clipboard
89 video.dir = ~/Videos absolute or ~/ path; default XDG Videos
90
91Script commands (never open the selector):
92 outputs list outputs
93 screenshot save a PNG screenshot
94 record record silent video until interrupted or target video length is reached
95
96Other:
97 version print the nefercap version
98
99Flags:
100 -clipboard copy PNG with wl-copy; no file unless -file is given (shot, screenshot)
101 -debug enable debug logging (all commands)
102 -output NAME output name (all except stop, status, outputs)
103 -file PATH new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it
104 -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
105 -fps N frames per second, 1..%d (rec, record, default %d)
106 -size WxH scaled video size, both even, at most %d each (rec, record, default source size)
107 -duration D encoded video length such as 30s; 0 records until stopped (rec, record)
108`, ports.MaxFPS, DefaultFPS, ports.MaxDimension)
109}
110
111// Parse parses args (without the program name). Help requests print Usage to
112// out and return flag.ErrHelp; other failures wrap ErrUsage.
113func Parse(args []string, out io.Writer) (Options, error) {
114 if out == nil {
115 out = io.Discard
116 }
117 opts := Options{Command: AllInOne}
118 if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
119 switch cmd := Command(args[0]); cmd {
120 case Shot, Rec, Stop, Status, Outputs, Screenshot, Record:
121 opts.Command = cmd
122 args = args[1:]
123 default:
124 return Options{}, usageErr("unknown command %q", args[0])
125 }
126 }
127
128 scripted := opts.Command == Screenshot || opts.Command == Record
129 interactive := opts.Command == Shot || opts.Command == Rec
130 video := opts.Command == Record || opts.Command == Rec
131
132 fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
133 fs.SetOutput(io.Discard)
134 fs.BoolVar(&opts.Debug, "debug", false, "")
135 var region, size string
136 fps := DefaultFPS
137 if scripted || interactive {
138 fs.StringVar(&opts.Output, "output", "", "")
139 fs.StringVar(&opts.Path, "file", "", "")
140 }
141 if scripted {
142 fs.StringVar(®ion, "region", "", "")
143 }
144 if opts.Command == Shot || opts.Command == Screenshot {
145 fs.BoolVar(&opts.Clipboard, "clipboard", false, "")
146 }
147 if video {
148 fs.IntVar(&fps, "fps", DefaultFPS, "")
149 fs.StringVar(&size, "size", "", "")
150 fs.DurationVar(&opts.Duration, "duration", 0, "")
151 }
152
153 if err := fs.Parse(args); err != nil {
154 if errors.Is(err, flag.ErrHelp) {
155 _, _ = io.WriteString(out, Usage())
156 return Options{}, flag.ErrHelp
157 }
158 if opts.Command == AllInOne {
159 return Options{}, errAllInOneFlags
160 }
161 return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
162 }
163 if fs.NArg() > 0 {
164 return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
165 }
166 if !scripted && !interactive {
167 return opts, nil
168 }
169
170 if scripted && opts.Path == "" && !opts.Clipboard {
171 return Options{}, usageErr("-file is required")
172 }
173 if interactive || opts.Clipboard {
174 // Set explicitly so "-file ''" is rejected rather than treated as auto.
175 explicit := false
176 fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" })
177 if explicit && opts.Path == "" {
178 return Options{}, usageErr("-file must not be empty")
179 }
180 }
181 if region != "" {
182 r, err := parseRegion(region)
183 if err != nil {
184 return Options{}, usageErr("invalid -region %q: %v", region, err)
185 }
186 opts.Region = r
187 }
188 if video {
189 if fps < 1 || fps > ports.MaxFPS {
190 return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
191 }
192 opts.Video.FPS = fps
193 if size != "" {
194 w, h, err := parseSize(size)
195 if err != nil {
196 return Options{}, usageErr("invalid -size %q: %v", size, err)
197 }
198 if w%2 != 0 || h%2 != 0 {
199 return Options{}, usageErr("invalid -size %q: width and height must be even", size)
200 }
201 if w > ports.MaxDimension || h > ports.MaxDimension || w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
202 return Options{}, usageErr("invalid -size %q: exceeds frame limits", size)
203 }
204 opts.Video.Width, opts.Video.Height = w, h
205 }
206 if opts.Duration < 0 {
207 return Options{}, usageErr("invalid -duration %s: must not be negative", opts.Duration)
208 }
209 }
210 return opts, nil
211}
212
213// usageError is a usage error that prints without the generic prefix.
214type usageError string
215
216func (e usageError) Error() string { return string(e) }
217func (e usageError) Is(target error) bool { return target == ErrUsage }
218
219const errAllInOneFlags = usageError("nefercap: all-in-one mode takes no flags except -debug; it reads $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config); use shot, rec, screenshot or record for per-run overrides")
220
221func usageErr(format string, a ...any) error {
222 return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
223}
224
225// parseRegion parses "X,Y,WxH" with nonnegative X,Y and positive W,H, all
226// within int32.
227func parseRegion(s string) (ports.Region, error) {
228 xy, wh, ok := strings.Cut(s, "x")
229 parts := strings.Split(xy, ",")
230 if !ok || len(parts) != 3 {
231 return ports.Region{}, errors.New("want X,Y,WxH")
232 }
233 h, err := parseInt(wh, 1)
234 if err != nil {
235 return ports.Region{}, err
236 }
237 x, err := parseInt(parts[0], 0)
238 if err != nil {
239 return ports.Region{}, err
240 }
241 y, err := parseInt(parts[1], 0)
242 if err != nil {
243 return ports.Region{}, err
244 }
245 w, err := parseInt(parts[2], 1)
246 if err != nil {
247 return ports.Region{}, err
248 }
249 if int64(x)+int64(w) > math.MaxInt32 || int64(y)+int64(h) > math.MaxInt32 {
250 return ports.Region{}, errors.New("region exceeds int32 bounds")
251 }
252 return ports.Region{X: x, Y: y, Width: w, Height: h}, nil
253}
254
255func parseSize(s string) (w, h int, err error) {
256 ws, hs, ok := strings.Cut(s, "x")
257 if !ok {
258 return 0, 0, errors.New("want WxH")
259 }
260 if w, err = parseInt(ws, 1); err != nil {
261 return 0, 0, err
262 }
263 if h, err = parseInt(hs, 1); err != nil {
264 return 0, 0, err
265 }
266 return w, h, nil
267}
268
269// parseInt accepts plain decimal digits only, at least min, at most MaxInt32.
270func parseInt(s string, min int) (int, error) {
271 if s == "" || strings.TrimLeft(s, "0123456789") != "" {
272 return 0, fmt.Errorf("%q is not a decimal number", s)
273 }
274 v, err := strconv.ParseInt(s, 10, 32)
275 if err != nil {
276 return 0, fmt.Errorf("%q is out of range", s)
277 }
278 if int(v) < min {
279 return 0, fmt.Errorf("%q must be at least %d", s, min)
280 }
281 return int(v), nil
282}