detached 1894f55

refactor(config): load the config with kvconf

commit
1894f55279a56d468fac8ce8fe647437dc70ba0d
author
brice · · 2026-10-10 03:02 UTC
parent
bd04964
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +70 -101
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.0
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.0
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 internal/adapters/config/config.go +69 -101
@@ -4,12 +4,10 @@ package config
44 import (
55 "errors"
66 "fmt"
7- "io"
87 "os"
9- "path/filepath"
10- "slices"
118 "strings"
12- "syscall"
9+
10+ "github.com/bnema/kvconf"
1311 )
1412
1513 const (
@@ -40,114 +38,84 @@ type Settings struct {
4038
4139 const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"
4240
41+// Strict: a line without "=", an unknown key or a duplicate is fatal. A "#"
42+// only starts a comment at the start of a line, and values are never unquoted.
43+var options = kvconf.Options{Strict: true, Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes}
44+
45+// file is the config file as kvconf decodes it, before it becomes Settings.
46+type file struct {
47+ Grid onOff `kv:"selector.grid"`
48+ Freeze onOff `kv:"selector.freeze"`
49+ ShotDir dirPath `kv:"screenshot.dir"`
50+ VideoDir dirPath `kv:"video.dir"`
51+ Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"`
52+}
53+
54+// onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1).
55+type onOff bool
56+
57+func (o *onOff) UnmarshalText(text []byte) error {
58+ switch string(text) {
59+ case "on":
60+ *o = true
61+ case "off":
62+ *o = false
63+ default:
64+ return errors.New("must be on or off")
65+ }
66+ return nil
67+}
68+
69+// dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath.
70+type dirPath string
71+
72+func (d *dirPath) UnmarshalText(text []byte) error {
73+ if len(text) > maxPath {
74+ return errors.New("must be a non-empty path")
75+ }
76+ if string(text) == "~" {
77+ return errors.New("must be an absolute path or start with ~/")
78+ }
79+ var p kvconf.Path
80+ if err := p.UnmarshalText(text); err != nil {
81+ return err
82+ }
83+ *d = dirPath(p)
84+ return nil
85+}
86+
4387 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
4488 // An absent file or setting enables the full monitor grid and the frozen
4589 // screenshot selector. Invalid settings fail explicitly rather than silently
4690 // changing the requested appearance.
4791 func Load() (Settings, error) {
4892 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- }
63- if err != nil {
64- return Settings{}, fmt.Errorf("open config: %w", err)
65- }
66- defer file.Close()
67- info, err := file.Stat()
93+ path, err := kvconf.DefaultPath("nefercap", "config")
6894 if err != nil {
69- return Settings{}, fmt.Errorf("stat config: %w", err)
70- }
71- if !info.Mode().IsRegular() {
72- return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
73- }
74- data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
75- if err != nil {
76- return Settings{}, fmt.Errorf("read config %s: %w", path, err)
95+ return settings, nil
7796 }
78- if len(data) > maxBytes {
97+ data, err := kvconf.ReadFile(path, options)
98+ switch {
99+ case errors.Is(err, os.ErrNotExist):
100+ return settings, nil
101+ case errors.Is(err, kvconf.ErrTooLarge):
79102 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
103+ case err != nil && strings.HasSuffix(err.Error(), "not a regular file"):
104+ return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
105+ case err != nil:
106+ return Settings{}, fmt.Errorf("read config: %w", err)
80107 }
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)
108+ f := file{Grid: true, Freeze: true, Output: OutputFile}
109+ if _, err := kvconf.Unmarshal(data, &f, options); err != nil {
110+ var w kvconf.Warning
111+ if !errors.As(err, &w) {
112+ return Settings{}, fmt.Errorf("config %s: %w", path, err)
92113 }
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")
114+ msg := w.Key + " " + w.Msg
115+ if w.Key == "" || w.Msg == "unknown key" || strings.HasPrefix(w.Msg, "overrides line") {
116+ msg = "expected each of " + knownKeys + " at most once as key = value"
118117 }
119- case "screenshot.dir":
120- s.ScreenshotDir, err = dir(key, value)
121- case "video.dir":
122- s.VideoDir, err = dir(key, value)
123- }
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
133- }
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)
141- }
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)
146- }
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)
118+ return Settings{}, fmt.Errorf("config %s line %d: %s", path, w.Line, msg)
151119 }
152- return filepath.Clean(value), nil
120+ return Settings{bool(f.Grid), bool(f.Freeze), string(f.ShotDir), string(f.VideoDir), f.Output}, nil
153121 }
@@ -4,12 +4,10 @@ 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"
11 "strings"8 "strings"
12- "syscall"9+
10+ "github.com/bnema/kvconf"
13 )11 )
14 12
15 const (13 const (
@@ -40,114 +38,84 @@ type Settings struct {
40 38
41 const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"39 const knownKeys = "selector.grid, selector.freeze, screenshot.dir, screenshot.output, video.dir"
42 40
41+// Strict: a line without "=", an unknown key or a duplicate is fatal. A "#"
42+// only starts a comment at the start of a line, and values are never unquoted.
43+var options = kvconf.Options{Strict: true, Comments: kvconf.CommentLine, RawValues: true, MaxBytes: maxBytes}
44+
45+// file is the config file as kvconf decodes it, before it becomes Settings.
46+type file struct {
47+ Grid onOff `kv:"selector.grid"`
48+ Freeze onOff `kv:"selector.freeze"`
49+ ShotDir dirPath `kv:"screenshot.dir"`
50+ VideoDir dirPath `kv:"video.dir"`
51+ Output ScreenshotOutput `kv:"screenshot.output" oneof:"file|file+clipboard|clipboard"`
52+}
53+
54+// onOff accepts only "on" and "off" (kvconf's bool also takes true/yes/1).
55+type onOff bool
56+
57+func (o *onOff) UnmarshalText(text []byte) error {
58+ switch string(text) {
59+ case "on":
60+ *o = true
61+ case "off":
62+ *o = false
63+ default:
64+ return errors.New("must be on or off")
65+ }
66+ return nil
67+}
68+
69+// dirPath is a kvconf.Path that refuses a bare "~" and paths over maxPath.
70+type dirPath string
71+
72+func (d *dirPath) UnmarshalText(text []byte) error {
73+ if len(text) > maxPath {
74+ return errors.New("must be a non-empty path")
75+ }
76+ if string(text) == "~" {
77+ return errors.New("must be an absolute path or start with ~/")
78+ }
79+ var p kvconf.Path
80+ if err := p.UnmarshalText(text); err != nil {
81+ return err
82+ }
83+ *d = dirPath(p)
84+ return nil
85+}
86+
43 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).87 // Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
44 // An absent file or setting enables the full monitor grid and the frozen88 // An absent file or setting enables the full monitor grid and the frozen
45 // screenshot selector. Invalid settings fail explicitly rather than silently89 // screenshot selector. Invalid settings fail explicitly rather than silently
46 // changing the requested appearance.90 // changing the requested appearance.
47 func Load() (Settings, error) {91 func Load() (Settings, error) {
48 settings := Settings{Grid: true, Freeze: true, Output: OutputFile}92 settings := Settings{Grid: true, Freeze: true, Output: OutputFile}
49- root := os.Getenv("XDG_CONFIG_HOME")93+ path, err := kvconf.DefaultPath("nefercap", "config")
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 {
64- return Settings{}, fmt.Errorf("open config: %w", err)
65- }
66- defer file.Close()
67- info, err := file.Stat()
68 if err != nil {94 if err != nil {
69- return Settings{}, fmt.Errorf("stat config: %w", err)95+ return settings, nil
70- }
71- if !info.Mode().IsRegular() {
72- return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
73- }
74- data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
75- if err != nil {
76- return Settings{}, fmt.Errorf("read config %s: %w", path, err)
77 }96 }
78- if len(data) > maxBytes {97+ data, err := kvconf.ReadFile(path, options)
98+ switch {
99+ case errors.Is(err, os.ErrNotExist):
100+ return settings, nil
101+ case errors.Is(err, kvconf.ErrTooLarge):
79 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)102 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
103+ case err != nil && strings.HasSuffix(err.Error(), "not a regular file"):
104+ return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
105+ case err != nil:
106+ return Settings{}, fmt.Errorf("read config: %w", err)
80 }107 }
81- var seen [len(keys)]bool108+ f := file{Grid: true, Freeze: true, Output: OutputFile}
82- for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {109+ if _, err := kvconf.Unmarshal(data, &f, options); err != nil {
83- line := strings.TrimSpace(raw)110+ var w kvconf.Warning
84- if line == "" || strings.HasPrefix(line, "#") {111+ if !errors.As(err, &w) {
85- continue112+ return Settings{}, fmt.Errorf("config %s: %w", path, err)
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 }113 }
93- if slot < 0 || seen[slot] {114+ msg := w.Key + " " + w.Msg
94- return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys)115+ if w.Key == "" || w.Msg == "unknown key" || strings.HasPrefix(w.Msg, "overrides line") {
95- }116+ msg = "expected each of " + knownKeys + " at most once as key = value"
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 }117 }
119- case "screenshot.dir":118+ return Settings{}, fmt.Errorf("config %s line %d: %s", path, w.Line, msg)
120- s.ScreenshotDir, err = dir(key, value)
121- case "video.dir":
122- s.VideoDir, err = dir(key, value)
123- }
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
133- }
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)
141- }
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)
146- }
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)
151 }119 }
152- return filepath.Clean(value), nil120+ return Settings{bool(f.Grid), bool(f.Freeze), string(f.ShotDir), string(f.VideoDir), f.Output}, nil
153 }121 }

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