detached 9e4b5ff

Merge pull request 'refactor(config): load the config with kvconf' (#4) from refactor/kvconf into main

Reviewed-on: https://git.bnema.dev/bnema/nefercap/pulls/4
commit
9e4b5ff41b57abeac35dd39d1245e2b97435bdf5
author
bnema · · 2026-10-12 13:17 UTC
merge of
c00bc5f + 6243ac0 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
4 files · +107 -98
M go.mod +1 -0
@@ -4,6 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/kvconf v0.1.2
78 github.com/bnema/neferclient v0.3.0
89 github.com/bnema/nefergui v0.7.2
910 github.com/bnema/purego-libwayland v0.8.0
@@ -4,6 +4,7 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/kvconf v0.1.2
7 github.com/bnema/neferclient v0.3.08 github.com/bnema/neferclient v0.3.0
8 github.com/bnema/nefergui v0.7.29 github.com/bnema/nefergui v0.7.2
9 github.com/bnema/purego-libwayland v0.8.010 github.com/bnema/purego-libwayland v0.8.0
M go.sum +2 -0 generated file: collapsed
M internal/adapters/config/config.go +93 -98
@@ -4,12 +4,11 @@ package config
44 import (
55 "errors"
66 "fmt"
7- "io"
87 "os"
9- "path/filepath"
108 "slices"
119 "strings"
12- "syscall"
10+
11+ "github.com/bnema/kvconf"
1312 )
1413
1514 const (
@@ -38,116 +37,112 @@ type Settings struct {
3837 Output ScreenshotOutput
3938 }
4039
41-const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"
40+var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
41+
42+var 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.
47+var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes}
48+
49+// defaults are the settings of an absent file or key.
50+var defaults = Settings{Grid: true, Freeze: true, Output: OutputFile}
51+
52+// file is the config file as kvconf decodes it, before it becomes Settings.
53+type 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).
62+type onOff bool
63+
64+func (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.
77+type dirPath string
78+
79+func (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+}
4293
4394 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
4495 // An absent file or setting enables the full monitor grid and the frozen
4596 // screenshot selector. Invalid settings fail explicitly rather than silently
4697 // changing the requested appearance.
4798 func Load() (Settings, error) {
48- settings := Settings{Grid: true, Freeze: true, Output: OutputFile}
49- root := os.Getenv("XDG_CONFIG_HOME")
50- if !filepath.IsAbs(root) {
51- home, err := os.UserHomeDir()
52- if err != nil {
53- return settings, nil
54- }
55- root = filepath.Join(home, ".config")
56- }
57- path := filepath.Join(root, "nefercap", "config")
58- // Nonblocking open also protects against a FIFO substituted before Stat.
59- file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
60- if errors.Is(err, os.ErrNotExist) {
61- return settings, nil
62- }
99+ path, err := kvconf.DefaultPath("nefercap", "config")
63100 if err != nil {
64- return Settings{}, fmt.Errorf("open config: %w", err)
101+ return defaults, nil
65102 }
66- defer file.Close()
67- info, err := file.Stat()
68- if err != nil {
69- return Settings{}, fmt.Errorf("stat config: %w", err)
70- }
71- if !info.Mode().IsRegular() {
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):
72110 return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
111+ case err != nil:
112+ return Settings{}, fmt.Errorf("read config: %w", err)
73113 }
74- data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
114+ entries, syntax, err := kvconf.ParseBytes(data, options)
75115 if err != nil {
76- return Settings{}, fmt.Errorf("read config %s: %w", path, err)
77- }
78- if len(data) > maxBytes {
79- return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
116+ return Settings{}, fmt.Errorf("config %s: %w", path, err)
80117 }
81- var seen [len(keys)]bool
82- for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {
83- line := strings.TrimSpace(raw)
84- if line == "" || strings.HasPrefix(line, "#") {
85- continue
86- }
87- key, value, ok := strings.Cut(line, "=")
88- key, value = strings.TrimSpace(key), strings.TrimSpace(value)
89- slot := -1
90- if ok {
91- slot = slices.Index(keys[:], key)
92- }
93- if slot < 0 || seen[slot] {
94- return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys)
95- }
96- seen[slot] = true
97- if err := apply(&settings, key, value); err != nil {
98- return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err)
99- }
100- }
101- return settings, nil
102-}
103-
104-var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
105-
106-func apply(s *Settings, key, value string) (err error) {
107- switch key {
108- case "selector.grid":
109- s.Grid, err = onOff(key, value)
110- case "selector.freeze":
111- s.Freeze, err = onOff(key, value)
112- case "screenshot.output":
113- switch out := ScreenshotOutput(value); out {
114- case OutputFile, OutputFileClipboard, OutputClipboard:
115- s.Output = out
116- default:
117- return errors.New("screenshot.output must be file, file+clipboard or clipboard")
118- }
119- case "screenshot.dir":
120- s.ScreenshotDir, err = dir(key, value)
121- case "video.dir":
122- s.VideoDir, err = dir(key, value)
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)
123122 }
124- return err
125-}
126-
127-func onOff(key, value string) (bool, error) {
128- switch value {
129- case "on":
130- return true, nil
131- case "off":
132- return false, nil
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
133130 }
134- return false, fmt.Errorf("%s must be on or off", key)
135-}
136-
137-// dir accepts an absolute path or one starting with ~/ (the home directory).
138-func dir(key, value string) (string, error) {
139- if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
140- return "", fmt.Errorf("%s must be a non-empty path", key)
131+ if len(values) > 0 && (first == nil || values[0].Line < first.Line) {
132+ first, generic = &values[0], !slices.Contains(keys[:], values[0].Key)
141133 }
142- if strings.HasPrefix(value, "~/") {
143- home, err := os.UserHomeDir()
144- if err != nil || !filepath.IsAbs(home) {
145- return "", fmt.Errorf("%s: no absolute home directory for ~", key)
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"
146138 }
147- value = filepath.Join(home, value[1:])
148- }
149- if !filepath.IsAbs(value) {
150- return "", fmt.Errorf("%s must be an absolute path or start with ~/", key)
139+ return Settings{}, fmt.Errorf("config %s line %d: %s", path, first.Line, msg)
151140 }
152- return filepath.Clean(value), nil
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
153148 }
@@ -4,12 +4,11 @@ package config
4 import (4 import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- "io"
8 "os"7 "os"
9- "path/filepath"
10 "slices"8 "slices"
11 "strings"9 "strings"
12- "syscall"10+
11+ "github.com/bnema/kvconf"
13 )12 )
14 13
15 const (14 const (
@@ -38,116 +37,112 @@ type Settings struct {
38 Output ScreenshotOutput37 Output ScreenshotOutput
39 }38 }
40 39
41-const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"40+var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
41+
42+var 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.
47+var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes}
48+
49+// defaults are the settings of an absent file or key.
50+var defaults = Settings{Grid: true, Freeze: true, Output: OutputFile}
51+
52+// file is the config file as kvconf decodes it, before it becomes Settings.
53+type 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).
62+type onOff bool
63+
64+func (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.
77+type dirPath string
78+
79+func (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+}
42 93
43 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).94 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
44 // An absent file or setting enables the full monitor grid and the frozen95 // An absent file or setting enables the full monitor grid and the frozen
45 // screenshot selector. Invalid settings fail explicitly rather than silently96 // screenshot selector. Invalid settings fail explicitly rather than silently
46 // changing the requested appearance.97 // changing the requested appearance.
47 func Load() (Settings, error) {98 func Load() (Settings, error) {
48- settings := Settings{Grid: true, Freeze: true, Output: OutputFile}99+ path, err := kvconf.DefaultPath("nefercap", "config")
49- root := os.Getenv("XDG_CONFIG_HOME")
50- if !filepath.IsAbs(root) {
51- home, err := os.UserHomeDir()
52- if err != nil {
53- return settings, nil
54- }
55- root = filepath.Join(home, ".config")
56- }
57- path := filepath.Join(root, "nefercap", "config")
58- // Nonblocking open also protects against a FIFO substituted before Stat.
59- file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
60- if errors.Is(err, os.ErrNotExist) {
61- return settings, nil
62- }
63 if err != nil {100 if err != nil {
64- return Settings{}, fmt.Errorf("open config: %w", err)101+ return defaults, nil
65 }102 }
66- defer file.Close()103+ data, err := kvconf.ReadFile(path, options)
67- info, err := file.Stat()104+ switch {
68- if err != nil {105+ case errors.Is(err, os.ErrNotExist):
69- return Settings{}, fmt.Errorf("stat config: %w", err)106+ return defaults, nil
70- }107+ case errors.Is(err, kvconf.ErrTooLarge):
71- if !info.Mode().IsRegular() {108+ return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
109+ case errors.Is(err, kvconf.ErrNotRegular):
72 return Settings{}, fmt.Errorf("config %s: expected a regular file", path)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)
73 }113 }
74- data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))114+ entries, syntax, err := kvconf.ParseBytes(data, options)
75 if err != nil {115 if err != nil {
76- return Settings{}, fmt.Errorf("read config %s: %w", path, err)116+ return Settings{}, fmt.Errorf("config %s: %w", path, err)
77- }
78- if len(data) > maxBytes {
79- return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
80 }117 }
81- var seen [len(keys)]bool118+ f := file{Grid: onOff(defaults.Grid), Freeze: onOff(defaults.Freeze), Output: defaults.Output}
82- for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {119+ values, err := kvconf.Decode(entries, &f, options)
83- line := strings.TrimSpace(raw)120+ if err != nil {
84- if line == "" || strings.HasPrefix(line, "#") {121+ return Settings{}, fmt.Errorf("config %s: %w", path, err)
85- continue
86- }
87- key, value, ok := strings.Cut(line, "=")
88- key, value = strings.TrimSpace(key), strings.TrimSpace(value)
89- slot := -1
90- if ok {
91- slot = slices.Index(keys[:], key)
92- }
93- if slot < 0 || seen[slot] {
94- return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys)
95- }
96- seen[slot] = true
97- if err := apply(&settings, key, value); err != nil {
98- return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err)
99- }
100- }
101- return settings, nil
102-}
103-
104-var keys = [...]string{"selector.grid", "selector.freeze", "screenshot.dir", "screenshot.output", "video.dir"}
105-
106-func apply(s *Settings, key, value string) (err error) {
107- switch key {
108- case "selector.grid":
109- s.Grid, err = onOff(key, value)
110- case "selector.freeze":
111- s.Freeze, err = onOff(key, value)
112- case "screenshot.output":
113- switch out := ScreenshotOutput(value); out {
114- case OutputFile, OutputFileClipboard, OutputClipboard:
115- s.Output = out
116- default:
117- return errors.New("screenshot.output must be file, file+clipboard or clipboard")
118- }
119- case "screenshot.dir":
120- s.ScreenshotDir, err = dir(key, value)
121- case "video.dir":
122- s.VideoDir, err = dir(key, value)
123 }122 }
124- return err123+ // Fail on the first problem in line order. A line without "=" or a
125-}124+ // duplicate (from ParseBytes) wins a tie, as a repeated key is wrong
126-125+ // whatever its value.
127-func onOff(key, value string) (bool, error) {126+ var first *kvconf.Warning
128- switch value {127+ generic := false
129- case "on":128+ if len(syntax) > 0 {
130- return true, nil129+ first, generic = &syntax[0], true
131- case "off":
132- return false, nil
133 }130 }
134- return false, fmt.Errorf("%s must be on or off", key)131+ if len(values) > 0 && (first == nil || values[0].Line < first.Line) {
135-}132+ first, generic = &values[0], !slices.Contains(keys[:], values[0].Key)
136-
137-// dir accepts an absolute path or one starting with ~/ (the home directory).
138-func dir(key, value string) (string, error) {
139- if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
140- return "", fmt.Errorf("%s must be a non-empty path", key)
141 }133 }
142- if strings.HasPrefix(value, "~/") {134+ if first != nil {
143- home, err := os.UserHomeDir()135+ msg := first.Key + " " + first.Msg
144- if err != nil || !filepath.IsAbs(home) {136+ if generic {
145- return "", fmt.Errorf("%s: no absolute home directory for ~", key)137+ msg = "expected each of " + knownKeys + " at most once as key = value"
146 }138 }
147- value = filepath.Join(home, value[1:])139+ return Settings{}, fmt.Errorf("config %s line %d: %s", path, first.Line, msg)
148- }
149- if !filepath.IsAbs(value) {
150- return "", fmt.Errorf("%s must be an absolute path or start with ~/", key)
151 }140 }
152- return filepath.Clean(value), nil141+ 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
153 }148 }
M internal/adapters/config/config_test.go +11 -0
@@ -182,6 +182,17 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) {
182182 {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"},
183183 {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"},
184184 {"video.output = file", "line 1: expected each of"},
185+ // The first problem in line order is reported.
186+ {"selector.grid = maybe\nselector.grid = on", "line 1: selector.grid must be on or off"},
187+ {"selector.grid = maybe\nnoequals", "line 1: selector.grid must be on or off"},
188+ {"selector.grid = maybe\n= x", "line 1: selector.grid must be on or off"},
189+ {"selector.grid = maybe\nscreenshot.dir = /a\nscreenshot.dir = /b", "line 1: selector.grid must be on or off"},
190+ {"foo = 1\nbar", "line 1: expected each of"},
191+ {"selector.grid = on\nselector.grid = maybe", "line 2: expected each of"},
192+ // '#' only starts a comment at the start of a line; values stay raw.
193+ {"selector.grid = on # c", "line 1: selector.grid must be on or off"},
194+ {"screenshot.output = \"file\"", "line 1: screenshot.output must be"},
195+ {"# caf\xe9\nselector.grid = on", "not valid UTF-8"},
185196 } {
186197 t.Run(tc.text, func(t *testing.T) {
187198 t.Setenv("HOME", t.TempDir())
@@ -182,6 +182,17 @@ func TestLoadRejectsInvalidNewKeys(t *testing.T) {
182 {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"},182 {"screenshot.output = file\n\nscreenshot.output = file", "line 3: expected each of"},
183 {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"},183 {"video.dir = /a\nvideo.dir = /a", "line 2: expected each of"},
184 {"video.output = file", "line 1: expected each of"},184 {"video.output = file", "line 1: expected each of"},
185+ // The first problem in line order is reported.
186+ {"selector.grid = maybe\nselector.grid = on", "line 1: selector.grid must be on or off"},
187+ {"selector.grid = maybe\nnoequals", "line 1: selector.grid must be on or off"},
188+ {"selector.grid = maybe\n= x", "line 1: selector.grid must be on or off"},
189+ {"selector.grid = maybe\nscreenshot.dir = /a\nscreenshot.dir = /b", "line 1: selector.grid must be on or off"},
190+ {"foo = 1\nbar", "line 1: expected each of"},
191+ {"selector.grid = on\nselector.grid = maybe", "line 2: expected each of"},
192+ // '#' only starts a comment at the start of a line; values stay raw.
193+ {"selector.grid = on # c", "line 1: selector.grid must be on or off"},
194+ {"screenshot.output = \"file\"", "line 1: screenshot.output must be"},
195+ {"# caf\xe9\nselector.grid = on", "not valid UTF-8"},
185 } {196 } {
186 t.Run(tc.text, func(t *testing.T) {197 t.Run(tc.text, func(t *testing.T) {
187 t.Setenv("HOME", t.TempDir())198 t.Setenv("HOME", t.TempDir())

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