feat/freeze-key 753c87f

cli.go RawHistoryFolder

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, or the monitor with a warning when
79              none is known; click or Enter confirms
80  1..9        pick that whole monitor from any overlay
81  g           toggle full-monitor center and thirds guides (on by default)
82  f           switch between the frozen and the live screen (screenshot mode,
83              selector.freeze = on)
84  Tab         switch shot <-> rec (all-in-one only)
85  Escape      cancel
86
87Config: $XDG_CONFIG_HOME/nefercap/config (default ~/.config/nefercap/config)
88  selector.grid = off                 hide guides on startup; G still toggles them
89  selector.freeze = off               screenshot selector shows the live screen (default on: still copy)
90  screenshot.dir = ~/Pictures/Shots   absolute or ~/ path; default XDG Pictures/Screenshots
91  screenshot.output = file+clipboard  file (default), file+clipboard or clipboard
92  video.dir = ~/Videos                absolute or ~/ path; default XDG Videos
93
94Script commands (never open the selector):
95  outputs     list outputs
96  screenshot  save a PNG screenshot
97  record      record silent video until interrupted or target video length is reached
98
99Other:
100  version     print the nefercap version
101
102Flags:
103  -clipboard        copy PNG with wl-copy; no file unless -file is given (shot, screenshot)
104  -debug            enable debug logging (all commands)
105  -output NAME      output name (all except stop, status, outputs)
106  -file PATH        new file to write; optional for shot, rec; screenshot requires it unless -clipboard; record requires it
107  -region X,Y,WxH   output-local logical region; default is the full output (screenshot, record)
108  -fps N            frames per second, 1..%d (rec, record, default %d)
109  -size WxH         scaled video size, both even, at most %d each (rec, record, default source size)
110  -duration D       encoded video length such as 30s; 0 records until stopped (rec, record)
111`, ports.MaxFPS, DefaultFPS, ports.MaxDimension)
112}
113
114// Parse parses args (without the program name). Help requests print Usage to
115// out and return flag.ErrHelp; other failures wrap ErrUsage.
116func Parse(args []string, out io.Writer) (Options, error) {
117	if out == nil {
118		out = io.Discard
119	}
120	opts := Options{Command: AllInOne}
121	if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
122		switch cmd := Command(args[0]); cmd {
123		case Shot, Rec, Stop, Status, Outputs, Screenshot, Record:
124			opts.Command = cmd
125			args = args[1:]
126		default:
127			return Options{}, usageErr("unknown command %q", args[0])
128		}
129	}
130
131	scripted := opts.Command == Screenshot || opts.Command == Record
132	interactive := opts.Command == Shot || opts.Command == Rec
133	video := opts.Command == Record || opts.Command == Rec
134
135	fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
136	fs.SetOutput(io.Discard)
137	fs.BoolVar(&opts.Debug, "debug", false, "")
138	var region, size string
139	fps := DefaultFPS
140	if scripted || interactive {
141		fs.StringVar(&opts.Output, "output", "", "")
142		fs.StringVar(&opts.Path, "file", "", "")
143	}
144	if scripted {
145		fs.StringVar(&region, "region", "", "")
146	}
147	if opts.Command == Shot || opts.Command == Screenshot {
148		fs.BoolVar(&opts.Clipboard, "clipboard", false, "")
149	}
150	if video {
151		fs.IntVar(&fps, "fps", DefaultFPS, "")
152		fs.StringVar(&size, "size", "", "")
153		fs.DurationVar(&opts.Duration, "duration", 0, "")
154	}
155
156	if err := fs.Parse(args); err != nil {
157		if errors.Is(err, flag.ErrHelp) {
158			_, _ = io.WriteString(out, Usage())
159			return Options{}, flag.ErrHelp
160		}
161		if opts.Command == AllInOne {
162			return Options{}, errAllInOneFlags
163		}
164		return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
165	}
166	if fs.NArg() > 0 {
167		return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
168	}
169	if !scripted && !interactive {
170		return opts, nil
171	}
172
173	if scripted && opts.Path == "" && !opts.Clipboard {
174		return Options{}, usageErr("-file is required")
175	}
176	if interactive || opts.Clipboard {
177		// Set explicitly so "-file ''" is rejected rather than treated as auto.
178		explicit := false
179		fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "file" })
180		if explicit && opts.Path == "" {
181			return Options{}, usageErr("-file must not be empty")
182		}
183	}
184	if region != "" {
185		r, err := parseRegion(region)
186		if err != nil {
187			return Options{}, usageErr("invalid -region %q: %v", region, err)
188		}
189		opts.Region = r
190	}
191	if video {
192		if fps < 1 || fps > ports.MaxFPS {
193			return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
194		}
195		opts.Video.FPS = fps
196		if size != "" {
197			w, h, err := parseSize(size)
198			if err != nil {
199				return Options{}, usageErr("invalid -size %q: %v", size, err)
200			}
201			if w%2 != 0 || h%2 != 0 {
202				return Options{}, usageErr("invalid -size %q: width and height must be even", size)
203			}
204			if w > ports.MaxDimension || h > ports.MaxDimension || w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
205				return Options{}, usageErr("invalid -size %q: exceeds frame limits", size)
206			}
207			opts.Video.Width, opts.Video.Height = w, h
208		}
209		if opts.Duration < 0 {
210			return Options{}, usageErr("invalid -duration %s: must not be negative", opts.Duration)
211		}
212	}
213	return opts, nil
214}
215
216// usageError is a usage error that prints without the generic prefix.
217type usageError string
218
219func (e usageError) Error() string        { return string(e) }
220func (e usageError) Is(target error) bool { return target == ErrUsage }
221
222const 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")
223
224func usageErr(format string, a ...any) error {
225	return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
226}
227
228// parseRegion parses "X,Y,WxH" with nonnegative X,Y and positive W,H, all
229// within int32.
230func parseRegion(s string) (ports.Region, error) {
231	xy, wh, ok := strings.Cut(s, "x")
232	parts := strings.Split(xy, ",")
233	if !ok || len(parts) != 3 {
234		return ports.Region{}, errors.New("want X,Y,WxH")
235	}
236	h, err := parseInt(wh, 1)
237	if err != nil {
238		return ports.Region{}, err
239	}
240	x, err := parseInt(parts[0], 0)
241	if err != nil {
242		return ports.Region{}, err
243	}
244	y, err := parseInt(parts[1], 0)
245	if err != nil {
246		return ports.Region{}, err
247	}
248	w, err := parseInt(parts[2], 1)
249	if err != nil {
250		return ports.Region{}, err
251	}
252	if int64(x)+int64(w) > math.MaxInt32 || int64(y)+int64(h) > math.MaxInt32 {
253		return ports.Region{}, errors.New("region exceeds int32 bounds")
254	}
255	return ports.Region{X: x, Y: y, Width: w, Height: h}, nil
256}
257
258func parseSize(s string) (w, h int, err error) {
259	ws, hs, ok := strings.Cut(s, "x")
260	if !ok {
261		return 0, 0, errors.New("want WxH")
262	}
263	if w, err = parseInt(ws, 1); err != nil {
264		return 0, 0, err
265	}
266	if h, err = parseInt(hs, 1); err != nil {
267		return 0, 0, err
268	}
269	return w, h, nil
270}
271
272// parseInt accepts plain decimal digits only, at least min, at most MaxInt32.
273func parseInt(s string, min int) (int, error) {
274	if s == "" || strings.TrimLeft(s, "0123456789") != "" {
275		return 0, fmt.Errorf("%q is not a decimal number", s)
276	}
277	v, err := strconv.ParseInt(s, 10, 32)
278	if err != nil {
279		return 0, fmt.Errorf("%q is out of range", s)
280	}
281	if int(v) < min {
282		return 0, fmt.Errorf("%q must be at least %d", s, min)
283	}
284	return int(v), nil
285}

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