1// Package config reads nefercap's persistent settings.
2package config
3
4import (
5 "errors"
6 "fmt"
7 "os"
8 "slices"
9 "strings"
10
11 "github.com/bnema/kvconf"
12)
13
14const (
15 maxBytes = 4096
16 maxPath = 4096
17)
18
19// ScreenshotOutput says where an interactive screenshot goes.
20type ScreenshotOutput string
21
22const (
23 OutputFile ScreenshotOutput = "file"
24 OutputFileClipboard ScreenshotOutput = "file+clipboard"
25 OutputClipboard ScreenshotOutput = "clipboard"
26)
27
28// Settings holds the persistent configuration. G toggles Grid for the current
29// selection without changing the saved setting. Freeze shows a still copy of
30// the screen while a screenshot is selected, and captures that copy. The
31// directories are absolute, or empty for the XDG default.
32type Settings struct {
33 Grid bool
34 Freeze bool
35 ScreenshotDir string
36 VideoDir string
37 Output ScreenshotOutput
38}
39
40var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
41
42var knownKeys = strings.Join(keys[:], ", ")
43
44// A "#" only starts a comment at the start of a line, and values are never
45// unquoted. Every problem is collected, so Load can fail on the first one in
46// line order: a line without "=", an unknown key, a duplicate or a bad value.
47var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes}
48
49// defaults are the settings of an absent file or key.
50var defaults = Settings{Grid: true, Freeze: true, Output: OutputFile}
51
52// file is the config file as kvconf decodes it, before it becomes Settings.
53type file struct {
54 Grid onOff `kv:"selector.grid"`
55 Freeze onOff `kv:"selector.freeze"`
56 ShotDir dirPath `kv:"screenshot.dir"`
57 VideoDir dirPath `kv:"video.dir"`
58 Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"`
59}
60
61// onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1).
62type onOff bool
63
64func (o *onOff) UnmarshalText(text []byte) error {
65 switch string(text) {
66 case "on":
67 *o = true
68 case "off":
69 *o = false
70 default:
71 return kvconf.ValueError("must be on or off")
72 }
73 return nil
74}
75
76// dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath.
77type dirPath string
78
79func (d *dirPath) UnmarshalText(text []byte) error {
80 if len(text) > maxPath {
81 return kvconf.ValueError(fmt.Sprintf("must be at most %d bytes", maxPath))
82 }
83 if string(text) == "~" {
84 return kvconf.ValueError("must be an absolute path or start with ~/")
85 }
86 var p kvconf.Path
87 if err := p.UnmarshalText(text); err != nil {
88 return err
89 }
90 *d = dirPath(p)
91 return nil
92}
93
94// Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
95// An absent file or setting enables the full monitor grid and the frozen
96// screenshot selector. Invalid settings fail explicitly rather than silently
97// changing the requested appearance.
98func Load() (Settings, error) {
99 path, err := kvconf.DefaultPath("nefercap", "config")
100 if err != nil {
101 return defaults, nil
102 }
103 data, err := kvconf.ReadFile(path, options)
104 switch {
105 case errors.Is(err, os.ErrNotExist):
106 return defaults, nil
107 case errors.Is(err, kvconf.ErrTooLarge):
108 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
109 case errors.Is(err, kvconf.ErrNotRegular):
110 return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
111 case err != nil:
112 return Settings{}, fmt.Errorf("read config: %w", err)
113 }
114 entries, syntax, err := kvconf.ParseBytes(data, options)
115 if err != nil {
116 return Settings{}, fmt.Errorf("config %s: %w", path, err)
117 }
118 f := file{Grid: onOff(defaults.Grid), Freeze: onOff(defaults.Freeze), Output: defaults.Output}
119 values, err := kvconf.Decode(entries, &f, options)
120 if err != nil {
121 return Settings{}, fmt.Errorf("config %s: %w", path, err)
122 }
123 // Fail on the first problem in line order. A line without "=" or a
124 // duplicate (from ParseBytes) wins a tie, as a repeated key is wrong
125 // whatever its value.
126 var first *kvconf.Warning
127 generic := false
128 if len(syntax) > 0 {
129 first, generic = &syntax[0], true
130 }
131 if len(values) > 0 && (first == nil || values[0].Line < first.Line) {
132 first, generic = &values[0], !slices.Contains(keys[:], values[0].Key)
133 }
134 if first != nil {
135 msg := first.Key + " " + first.Msg
136 if generic {
137 msg = "expected each of " + knownKeys + " at most once as key = value"
138 }
139 return Settings{}, fmt.Errorf("config %s line %d: %s", path, first.Line, msg)
140 }
141 return Settings{
142 Grid: bool(f.Grid),
143 Freeze: bool(f.Freeze),
144 ScreenshotDir: string(f.ShotDir),
145 VideoDir: string(f.VideoDir),
146 Output: f.Output,
147 }, nil
148}