detached 42465c1

feat(config): load and validate TOML config with validate-config command

commit
42465c196ead3198433ce424533585b519e7bf7e
author
brice · · 2026-09-23 10:38 UTC
parent
4308b69
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +395 -3
M README.md +13 -0
@@ -7,3 +7,16 @@ Requires Linux and Go 1.27.
77 Build: `make build`
88
99 Run: `nefertty --backend=headless --timeout 5s`
10+
11+## Configuration
12+
13+Configuration lives at `$XDG_CONFIG_HOME/nefertty/config.toml` (or `~/.config/nefertty/config.toml`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor.
14+
15+```toml
16+[terminal]
17+command = ["foot"]
18+[layout]
19+gaps = 8
20+```
21+
22+See `examples/config.toml` for all defaults.
@@ -7,3 +7,16 @@ Requires Linux and Go 1.27.
7 Build: `make build`7 Build: `make build`
8 8
9 Run: `nefertty --backend=headless --timeout 5s`9 Run: `nefertty --backend=headless --timeout 5s`
10+
11+## Configuration
12+
13+Configuration lives at `$XDG_CONFIG_HOME/nefertty/config.toml` (or `~/.config/nefertty/config.toml`). Use `nefertty --config path` to select another file; `nefertty validate-config [path]` checks it without starting the compositor.
14+
15+```toml
16+[terminal]
17+command = ["foot"]
18+[layout]
19+gaps = 8
20+```
21+
22+See `examples/config.toml` for all defaults.
M cmd/nefertty/main.go +31 -2
@@ -9,6 +9,7 @@ import (
99 "runtime/debug"
1010 "syscall"
1111
12+ "github.com/bnema/nefertty/internal/adapters/config"
1213 "github.com/bnema/nefertty/internal/app"
1314 "github.com/bnema/nefertty/internal/logging"
1415 )
@@ -20,6 +21,23 @@ func main() {
2021 }
2122
2223 func run() error {
24+ if len(os.Args) > 1 && os.Args[1] == "validate-config" {
25+ if len(os.Args) > 3 {
26+ err := fmt.Errorf("usage: validate-config [path]")
27+ fmt.Fprintln(os.Stderr, err)
28+ return err
29+ }
30+ path := config.DefaultPath()
31+ if len(os.Args) == 3 {
32+ path = os.Args[2]
33+ }
34+ if _, err := config.Load(path); err != nil {
35+ fmt.Fprintln(os.Stderr, err)
36+ return err
37+ }
38+ fmt.Println("ok: " + path)
39+ return nil
40+ }
2341 if len(os.Args) == 2 && os.Args[1] == "version" {
2442 info, ok := debug.ReadBuildInfo()
2543 if !ok {
@@ -37,7 +55,7 @@ func run() error {
3755 backend := flags.String("backend", "drm", "drm or headless")
3856 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
3957 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
40- config := flags.String("config", "", "config path (empty uses XDG default later)")
58+ configFlag := flags.String("config", "", "config path (empty uses XDG default)")
4159 if err := flags.Parse(os.Args[1:]); err != nil {
4260 return err
4361 }
@@ -65,7 +83,18 @@ func run() error {
6583 log.Error().Err(err).Msg("invalid options")
6684 return err
6785 }
68- if err := app.Run(ctx, app.Options{Backend: *backend, Config: *config, Timeout: *timeout}); err != nil {
86+ path := *configFlag
87+ if path == "" {
88+ path = config.DefaultPath()
89+ }
90+ cfg, err := config.Load(path)
91+ if err != nil {
92+ fmt.Fprintln(os.Stderr, err)
93+ return err
94+ }
95+ configLog := logging.For(ctx, "config")
96+ configLog.Info().Str("path", path).Msg("loaded config")
97+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {
6998 log.Error().Err(err).Msg("application failed")
7099 return err
71100 }
@@ -9,6 +9,7 @@ import (
9 "runtime/debug"9 "runtime/debug"
10 "syscall"10 "syscall"
11 11
12+ "github.com/bnema/nefertty/internal/adapters/config"
12 "github.com/bnema/nefertty/internal/app"13 "github.com/bnema/nefertty/internal/app"
13 "github.com/bnema/nefertty/internal/logging"14 "github.com/bnema/nefertty/internal/logging"
14 )15 )
@@ -20,6 +21,23 @@ func main() {
20 }21 }
21 22
22 func run() error {23 func run() error {
24+ if len(os.Args) > 1 && os.Args[1] == "validate-config" {
25+ if len(os.Args) > 3 {
26+ err := fmt.Errorf("usage: validate-config [path]")
27+ fmt.Fprintln(os.Stderr, err)
28+ return err
29+ }
30+ path := config.DefaultPath()
31+ if len(os.Args) == 3 {
32+ path = os.Args[2]
33+ }
34+ if _, err := config.Load(path); err != nil {
35+ fmt.Fprintln(os.Stderr, err)
36+ return err
37+ }
38+ fmt.Println("ok: " + path)
39+ return nil
40+ }
23 if len(os.Args) == 2 && os.Args[1] == "version" {41 if len(os.Args) == 2 && os.Args[1] == "version" {
24 info, ok := debug.ReadBuildInfo()42 info, ok := debug.ReadBuildInfo()
25 if !ok {43 if !ok {
@@ -37,7 +55,7 @@ func run() error {
37 backend := flags.String("backend", "drm", "drm or headless")55 backend := flags.String("backend", "drm", "drm or headless")
38 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")56 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
39 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")57 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
40- config := flags.String("config", "", "config path (empty uses XDG default later)")58+ configFlag := flags.String("config", "", "config path (empty uses XDG default)")
41 if err := flags.Parse(os.Args[1:]); err != nil {59 if err := flags.Parse(os.Args[1:]); err != nil {
42 return err60 return err
43 }61 }
@@ -65,7 +83,18 @@ func run() error {
65 log.Error().Err(err).Msg("invalid options")83 log.Error().Err(err).Msg("invalid options")
66 return err84 return err
67 }85 }
68- if err := app.Run(ctx, app.Options{Backend: *backend, Config: *config, Timeout: *timeout}); err != nil {86+ path := *configFlag
87+ if path == "" {
88+ path = config.DefaultPath()
89+ }
90+ cfg, err := config.Load(path)
91+ if err != nil {
92+ fmt.Fprintln(os.Stderr, err)
93+ return err
94+ }
95+ configLog := logging.For(ctx, "config")
96+ configLog.Info().Str("path", path).Msg("loaded config")
97+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {
69 log.Error().Err(err).Msg("application failed")98 log.Error().Err(err).Msg("application failed")
70 return err99 return err
71 }100 }
A examples/config.toml +38 -0
@@ -0,0 +1,38 @@
1+[keyboard]
2+layout = ""
3+variant = ""
4+options = ""
5+repeat_rate = 25
6+repeat_delay = 600
7+cmd_key = "super"
8+
9+[terminal]
10+command = ["foot"]
11+
12+[background]
13+color = "#1e1e2e"
14+
15+[layout]
16+gaps = 8
17+default_column_width = "1/2"
18+presets = ["1/3", "1/2", "2/3", "1"]
19+
20+[render]
21+direct_scanout = true
22+
23+[log]
24+level = "info"
25+debug = []
26+
27+[binds]
28+"Cmd+Return" = "spawn-terminal"
29+"Cmd+Left" = "focus-column-left"
30+"Cmd+Right" = "focus-column-right"
31+"Cmd+Up" = "focus-window-up"
32+"Cmd+Down" = "focus-window-down"
33+"Cmd+Shift+Left" = "move-column-left"
34+"Cmd+Shift+Right" = "move-column-right"
35+"Cmd+R" = "cycle-column-width"
36+"Cmd+Shift+F" = "toggle-fullscreen"
37+"Cmd+Q" = "close-window"
38+"Ctrl+Alt+BackSpace" = "quit"
@@ -0,0 +1,38 @@
1+[keyboard]
2+layout = ""
3+variant = ""
4+options = ""
5+repeat_rate = 25
6+repeat_delay = 600
7+cmd_key = "super"
8+
9+[terminal]
10+command = ["foot"]
11+
12+[background]
13+color = "#1e1e2e"
14+
15+[layout]
16+gaps = 8
17+default_column_width = "1/2"
18+presets = ["1/3", "1/2", "2/3", "1"]
19+
20+[render]
21+direct_scanout = true
22+
23+[log]
24+level = "info"
25+debug = []
26+
27+[binds]
28+"Cmd+Return" = "spawn-terminal"
29+"Cmd+Left" = "focus-column-left"
30+"Cmd+Right" = "focus-column-right"
31+"Cmd+Up" = "focus-window-up"
32+"Cmd+Down" = "focus-window-down"
33+"Cmd+Shift+Left" = "move-column-left"
34+"Cmd+Shift+Right" = "move-column-right"
35+"Cmd+R" = "cycle-column-width"
36+"Cmd+Shift+F" = "toggle-fullscreen"
37+"Cmd+Q" = "close-window"
38+"Ctrl+Alt+BackSpace" = "quit"
M go.mod +1 -0
@@ -9,6 +9,7 @@ require (
99 )
1010
1111 require (
12+ github.com/BurntSushi/toml v1.5.0
1213 github.com/mattn/go-colorable v0.1.15 // indirect
1314 github.com/mattn/go-isatty v0.0.22 // indirect
1415 golang.org/x/sys v0.48.0 // indirect
@@ -9,6 +9,7 @@ require (
9 )9 )
10 10
11 require (11 require (
12+ github.com/BurntSushi/toml v1.5.0
12 github.com/mattn/go-colorable v0.1.15 // indirect13 github.com/mattn/go-colorable v0.1.15 // indirect
13 github.com/mattn/go-isatty v0.0.22 // indirect14 github.com/mattn/go-isatty v0.0.22 // indirect
14 golang.org/x/sys v0.48.0 // indirect15 golang.org/x/sys v0.48.0 // indirect
M go.sum +2 -0 generated file: collapsed
A internal/adapters/config/config.go +201 -0
@@ -0,0 +1,201 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "sort"
10+ "strconv"
11+ "strings"
12+
13+ "github.com/BurntSushi/toml"
14+ "github.com/bnema/nefertty/internal/ports"
15+)
16+
17+var defaultBinds = map[string]string{
18+ "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
19+}
20+var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21+var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true}
22+var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
23+
24+func DefaultPath() string {
25+ base := os.Getenv("XDG_CONFIG_HOME")
26+ if base == "" {
27+ home, err := os.UserHomeDir()
28+ if err == nil {
29+ base = filepath.Join(home, ".config")
30+ }
31+ }
32+ return filepath.Join(base, "nefertty", "config.toml")
33+}
34+func Defaults() ports.Config {
35+ var c ports.Config
36+ c.Keyboard.RepeatRate = 25
37+ c.Keyboard.RepeatDelay = 600
38+ c.Keyboard.CmdKey = "super"
39+ c.Terminal.Command = []string{"foot"}
40+ c.Background.Color = "#1e1e2e"
41+ c.Layout.Gaps = 8
42+ c.Layout.DefaultColumnWidth = "1/2"
43+ c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
44+ c.Binds = make(map[string]string, len(defaultBinds))
45+ for k, v := range defaultBinds {
46+ c.Binds[k] = v
47+ }
48+ c.Render.DirectScanout = true
49+ c.Log.Level = "info"
50+ c.Log.Debug = []string{}
51+ return c
52+}
53+func Load(path string) (ports.Config, error) {
54+ c := Defaults()
55+ data, err := os.ReadFile(path)
56+ if os.IsNotExist(err) {
57+ return c, nil
58+ }
59+ if err != nil {
60+ return c, err
61+ }
62+ // Decode binds separately so user entries can remove or override defaults.
63+ var raw struct {
64+ Binds map[string]string `toml:"binds"`
65+ }
66+ if _, err = toml.Decode(string(data), &raw); err != nil {
67+ return c, err
68+ }
69+ c.Binds = make(map[string]string)
70+ md, err := toml.Decode(string(data), &c)
71+ if err != nil {
72+ return c, err
73+ }
74+ var errs []error
75+ for _, key := range md.Undecoded() {
76+ errs = append(errs, fmt.Errorf("%s: unknown key", key.String()))
77+ }
78+ // Validate user bindings before merging to catch normalized collisions.
79+ errs = append(errs, Validate(c))
80+ if err = errors.Join(errs...); err != nil {
81+ return c, err
82+ }
83+ c.Binds = Defaults().Binds
84+ for k, v := range raw.Binds {
85+ normalized, _ := normalize(k)
86+ for old := range c.Binds {
87+ n, _ := normalize(old)
88+ if n == normalized {
89+ delete(c.Binds, old)
90+ }
91+ }
92+ if v != "none" {
93+ c.Binds[k] = v
94+ }
95+ }
96+ return c, nil
97+}
98+func width(s string) bool {
99+ if s == "1" {
100+ return true
101+ }
102+ if strings.HasSuffix(s, "px") {
103+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
104+ return e == nil && n > 0
105+ }
106+ p := strings.Split(s, "/")
107+ if len(p) != 2 {
108+ return false
109+ }
110+ a, e1 := strconv.Atoi(p[0])
111+ b, e2 := strconv.Atoi(p[1])
112+ return e1 == nil && e2 == nil && a > 0 && a <= b
113+}
114+func normalize(s string) (string, error) {
115+ p := strings.Split(s, "+")
116+ if len(p) < 2 || p[len(p)-1] == "" {
117+ return "", fmt.Errorf("invalid key combo")
118+ }
119+ seen := map[string]bool{}
120+ for _, m := range p[:len(p)-1] {
121+ if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] {
122+ return "", fmt.Errorf("invalid key combo")
123+ }
124+ seen[m] = true
125+ }
126+ mods := make([]string, 0, len(seen))
127+ for m := range seen {
128+ mods = append(mods, m)
129+ }
130+ sort.Strings(mods)
131+ return strings.Join(append(mods, p[len(p)-1]), "+"), nil
132+}
133+func Validate(c ports.Config) error {
134+ var errs []error
135+ add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) }
136+ if c.Keyboard.RepeatRate <= 0 {
137+ add("keyboard.repeat_rate", "must be positive")
138+ }
139+ if c.Keyboard.RepeatDelay <= 0 {
140+ add("keyboard.repeat_delay", "must be positive")
141+ }
142+ if c.Keyboard.CmdKey != "super" && c.Keyboard.CmdKey != "alt" && c.Keyboard.CmdKey != "ctrl" {
143+ add("keyboard.cmd_key", "must be super, alt or ctrl")
144+ }
145+ if len(c.Terminal.Command) == 0 {
146+ add("terminal.command", "must not be empty")
147+ } else {
148+ for _, s := range c.Terminal.Command {
149+ if s == "" {
150+ add("terminal.command", "must not contain empty entries")
151+ break
152+ }
153+ }
154+ }
155+ if !color.MatchString(c.Background.Color) {
156+ add("background.color", "must be #rrggbb")
157+ }
158+ if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
159+ add("layout.gaps", "must be between 0 and 200")
160+ }
161+ if !width(c.Layout.DefaultColumnWidth) {
162+ add("layout.default_column_width", "must be a positive fraction or pixel width")
163+ }
164+ if len(c.Layout.Presets) == 0 {
165+ add("layout.presets", "must not be empty")
166+ }
167+ for i, s := range c.Layout.Presets {
168+ if !width(s) {
169+ add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width")
170+ }
171+ }
172+ if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" {
173+ add("log.level", "invalid level")
174+ }
175+ for i, s := range c.Log.Debug {
176+ if !components[s] {
177+ add(fmt.Sprintf("log.debug[%d]", i), "invalid component")
178+ }
179+ }
180+ keys := make([]string, 0, len(c.Binds))
181+ for k := range c.Binds {
182+ keys = append(keys, k)
183+ }
184+ sort.Strings(keys)
185+ combos := map[string]string{}
186+ for _, k := range keys {
187+ v := c.Binds[k]
188+ n, e := normalize(k)
189+ if e != nil {
190+ add("binds."+k, e.Error())
191+ } else if old, ok := combos[n]; ok {
192+ add("binds."+k, "duplicates "+old)
193+ } else {
194+ combos[n] = k
195+ }
196+ if !actions[v] {
197+ add("binds."+k, "invalid action")
198+ }
199+ }
200+ return errors.Join(errs...)
201+}
@@ -0,0 +1,201 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "sort"
10+ "strconv"
11+ "strings"
12+
13+ "github.com/BurntSushi/toml"
14+ "github.com/bnema/nefertty/internal/ports"
15+)
16+
17+var defaultBinds = map[string]string{
18+ "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
19+}
20+var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21+var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true}
22+var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
23+
24+func DefaultPath() string {
25+ base := os.Getenv("XDG_CONFIG_HOME")
26+ if base == "" {
27+ home, err := os.UserHomeDir()
28+ if err == nil {
29+ base = filepath.Join(home, ".config")
30+ }
31+ }
32+ return filepath.Join(base, "nefertty", "config.toml")
33+}
34+func Defaults() ports.Config {
35+ var c ports.Config
36+ c.Keyboard.RepeatRate = 25
37+ c.Keyboard.RepeatDelay = 600
38+ c.Keyboard.CmdKey = "super"
39+ c.Terminal.Command = []string{"foot"}
40+ c.Background.Color = "#1e1e2e"
41+ c.Layout.Gaps = 8
42+ c.Layout.DefaultColumnWidth = "1/2"
43+ c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
44+ c.Binds = make(map[string]string, len(defaultBinds))
45+ for k, v := range defaultBinds {
46+ c.Binds[k] = v
47+ }
48+ c.Render.DirectScanout = true
49+ c.Log.Level = "info"
50+ c.Log.Debug = []string{}
51+ return c
52+}
53+func Load(path string) (ports.Config, error) {
54+ c := Defaults()
55+ data, err := os.ReadFile(path)
56+ if os.IsNotExist(err) {
57+ return c, nil
58+ }
59+ if err != nil {
60+ return c, err
61+ }
62+ // Decode binds separately so user entries can remove or override defaults.
63+ var raw struct {
64+ Binds map[string]string `toml:"binds"`
65+ }
66+ if _, err = toml.Decode(string(data), &raw); err != nil {
67+ return c, err
68+ }
69+ c.Binds = make(map[string]string)
70+ md, err := toml.Decode(string(data), &c)
71+ if err != nil {
72+ return c, err
73+ }
74+ var errs []error
75+ for _, key := range md.Undecoded() {
76+ errs = append(errs, fmt.Errorf("%s: unknown key", key.String()))
77+ }
78+ // Validate user bindings before merging to catch normalized collisions.
79+ errs = append(errs, Validate(c))
80+ if err = errors.Join(errs...); err != nil {
81+ return c, err
82+ }
83+ c.Binds = Defaults().Binds
84+ for k, v := range raw.Binds {
85+ normalized, _ := normalize(k)
86+ for old := range c.Binds {
87+ n, _ := normalize(old)
88+ if n == normalized {
89+ delete(c.Binds, old)
90+ }
91+ }
92+ if v != "none" {
93+ c.Binds[k] = v
94+ }
95+ }
96+ return c, nil
97+}
98+func width(s string) bool {
99+ if s == "1" {
100+ return true
101+ }
102+ if strings.HasSuffix(s, "px") {
103+ n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
104+ return e == nil && n > 0
105+ }
106+ p := strings.Split(s, "/")
107+ if len(p) != 2 {
108+ return false
109+ }
110+ a, e1 := strconv.Atoi(p[0])
111+ b, e2 := strconv.Atoi(p[1])
112+ return e1 == nil && e2 == nil && a > 0 && a <= b
113+}
114+func normalize(s string) (string, error) {
115+ p := strings.Split(s, "+")
116+ if len(p) < 2 || p[len(p)-1] == "" {
117+ return "", fmt.Errorf("invalid key combo")
118+ }
119+ seen := map[string]bool{}
120+ for _, m := range p[:len(p)-1] {
121+ if m != "Cmd" && m != "Shift" && m != "Ctrl" && m != "Alt" || seen[m] {
122+ return "", fmt.Errorf("invalid key combo")
123+ }
124+ seen[m] = true
125+ }
126+ mods := make([]string, 0, len(seen))
127+ for m := range seen {
128+ mods = append(mods, m)
129+ }
130+ sort.Strings(mods)
131+ return strings.Join(append(mods, p[len(p)-1]), "+"), nil
132+}
133+func Validate(c ports.Config) error {
134+ var errs []error
135+ add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) }
136+ if c.Keyboard.RepeatRate <= 0 {
137+ add("keyboard.repeat_rate", "must be positive")
138+ }
139+ if c.Keyboard.RepeatDelay <= 0 {
140+ add("keyboard.repeat_delay", "must be positive")
141+ }
142+ if c.Keyboard.CmdKey != "super" && c.Keyboard.CmdKey != "alt" && c.Keyboard.CmdKey != "ctrl" {
143+ add("keyboard.cmd_key", "must be super, alt or ctrl")
144+ }
145+ if len(c.Terminal.Command) == 0 {
146+ add("terminal.command", "must not be empty")
147+ } else {
148+ for _, s := range c.Terminal.Command {
149+ if s == "" {
150+ add("terminal.command", "must not contain empty entries")
151+ break
152+ }
153+ }
154+ }
155+ if !color.MatchString(c.Background.Color) {
156+ add("background.color", "must be #rrggbb")
157+ }
158+ if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
159+ add("layout.gaps", "must be between 0 and 200")
160+ }
161+ if !width(c.Layout.DefaultColumnWidth) {
162+ add("layout.default_column_width", "must be a positive fraction or pixel width")
163+ }
164+ if len(c.Layout.Presets) == 0 {
165+ add("layout.presets", "must not be empty")
166+ }
167+ for i, s := range c.Layout.Presets {
168+ if !width(s) {
169+ add(fmt.Sprintf("layout.presets[%d]", i), "must be a positive fraction or pixel width")
170+ }
171+ }
172+ if c.Log.Level != "debug" && c.Log.Level != "info" && c.Log.Level != "warn" && c.Log.Level != "error" {
173+ add("log.level", "invalid level")
174+ }
175+ for i, s := range c.Log.Debug {
176+ if !components[s] {
177+ add(fmt.Sprintf("log.debug[%d]", i), "invalid component")
178+ }
179+ }
180+ keys := make([]string, 0, len(c.Binds))
181+ for k := range c.Binds {
182+ keys = append(keys, k)
183+ }
184+ sort.Strings(keys)
185+ combos := map[string]string{}
186+ for _, k := range keys {
187+ v := c.Binds[k]
188+ n, e := normalize(k)
189+ if e != nil {
190+ add("binds."+k, e.Error())
191+ } else if old, ok := combos[n]; ok {
192+ add("binds."+k, "duplicates "+old)
193+ } else {
194+ combos[n] = k
195+ }
196+ if !actions[v] {
197+ add("binds."+k, "invalid action")
198+ }
199+ }
200+ return errors.Join(errs...)
201+}
A internal/adapters/config/config_test.go +81 -0
@@ -0,0 +1,81 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestDefaultsAndLoad(t *testing.T) {
12+ d := Defaults()
13+ if err := Validate(d); err != nil {
14+ t.Fatal(err)
15+ }
16+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 11 {
17+ t.Fatalf("defaults: %+v", d)
18+ }
19+ path := filepath.Join(t.TempDir(), "missing")
20+ got, err := Load(path)
21+ if err != nil || !reflect.DeepEqual(got, d) {
22+ t.Fatalf("missing: %v %+v", err, got)
23+ }
24+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
25+ if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config.toml") {
26+ t.Fatal(DefaultPath())
27+ }
28+ example, err := Load("../../../examples/config.toml")
29+ if err != nil || !reflect.DeepEqual(example, d) {
30+ t.Fatalf("example: %v %+v", err, example)
31+ }
32+}
33+func TestValidation(t *testing.T) {
34+ cases := []struct{ name, content, want string }{
35+ {"rate", "[keyboard]\nrepeat_rate=0", "keyboard.repeat_rate"},
36+ {"delay", "[keyboard]\nrepeat_delay=0", "keyboard.repeat_delay"},
37+ {"cmd", "[keyboard]\ncmd_key='meta'", "keyboard.cmd_key"},
38+ {"terminal", "[terminal]\ncommand=[]", "terminal.command"},
39+ {"color", "[background]\ncolor='red'", "background.color"},
40+ {"gaps", "[layout]\ngaps=201", "layout.gaps"},
41+ {"width", "[layout]\ndefault_column_width='2/1'", "layout.default_column_width"},
42+ {"preset", "[layout]\npresets=['0px']", "layout.presets[0]"},
43+ {"level", "[log]\nlevel='trace'", "log.level"},
44+ {"debug", "[log]\ndebug=['nope']", "log.debug[0]"},
45+ {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"},
46+ {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"},
47+ {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"},
48+ {"unknown", "[layout]\nnope=1", "layout.nope"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.name, func(t *testing.T) {
52+ path := filepath.Join(t.TempDir(), "config.toml")
53+ if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil {
54+ t.Fatal(err)
55+ }
56+ _, err := Load(path)
57+ if err == nil || !strings.Contains(err.Error(), tc.want) {
58+ t.Fatalf("got %v, want %s", err, tc.want)
59+ }
60+ })
61+ }
62+}
63+func TestBindMerge(t *testing.T) {
64+ path := filepath.Join(t.TempDir(), "config.toml")
65+ if err := os.WriteFile(path, []byte("[binds]\n'Cmd+Return'='none'\n'Shift+Cmd+Left'='quit'\n'Alt+X'='spawn-terminal'\n"), 0600); err != nil {
66+ t.Fatal(err)
67+ }
68+ c, err := Load(path)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if _, ok := c.Binds["Cmd+Return"]; ok {
73+ t.Fatal(c.Binds)
74+ }
75+ if _, ok := c.Binds["Cmd+Shift+Left"]; ok {
76+ t.Fatal(c.Binds)
77+ }
78+ if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" {
79+ t.Fatal(c.Binds)
80+ }
81+}
@@ -0,0 +1,81 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestDefaultsAndLoad(t *testing.T) {
12+ d := Defaults()
13+ if err := Validate(d); err != nil {
14+ t.Fatal(err)
15+ }
16+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.RepeatDelay != 600 || d.Keyboard.CmdKey != "super" || d.Render.DirectScanout != true || len(d.Binds) != 11 {
17+ t.Fatalf("defaults: %+v", d)
18+ }
19+ path := filepath.Join(t.TempDir(), "missing")
20+ got, err := Load(path)
21+ if err != nil || !reflect.DeepEqual(got, d) {
22+ t.Fatalf("missing: %v %+v", err, got)
23+ }
24+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
25+ if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "nefertty", "config.toml") {
26+ t.Fatal(DefaultPath())
27+ }
28+ example, err := Load("../../../examples/config.toml")
29+ if err != nil || !reflect.DeepEqual(example, d) {
30+ t.Fatalf("example: %v %+v", err, example)
31+ }
32+}
33+func TestValidation(t *testing.T) {
34+ cases := []struct{ name, content, want string }{
35+ {"rate", "[keyboard]\nrepeat_rate=0", "keyboard.repeat_rate"},
36+ {"delay", "[keyboard]\nrepeat_delay=0", "keyboard.repeat_delay"},
37+ {"cmd", "[keyboard]\ncmd_key='meta'", "keyboard.cmd_key"},
38+ {"terminal", "[terminal]\ncommand=[]", "terminal.command"},
39+ {"color", "[background]\ncolor='red'", "background.color"},
40+ {"gaps", "[layout]\ngaps=201", "layout.gaps"},
41+ {"width", "[layout]\ndefault_column_width='2/1'", "layout.default_column_width"},
42+ {"preset", "[layout]\npresets=['0px']", "layout.presets[0]"},
43+ {"level", "[log]\nlevel='trace'", "log.level"},
44+ {"debug", "[log]\ndebug=['nope']", "log.debug[0]"},
45+ {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"},
46+ {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"},
47+ {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"},
48+ {"unknown", "[layout]\nnope=1", "layout.nope"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.name, func(t *testing.T) {
52+ path := filepath.Join(t.TempDir(), "config.toml")
53+ if err := os.WriteFile(path, []byte(tc.content), 0600); err != nil {
54+ t.Fatal(err)
55+ }
56+ _, err := Load(path)
57+ if err == nil || !strings.Contains(err.Error(), tc.want) {
58+ t.Fatalf("got %v, want %s", err, tc.want)
59+ }
60+ })
61+ }
62+}
63+func TestBindMerge(t *testing.T) {
64+ path := filepath.Join(t.TempDir(), "config.toml")
65+ if err := os.WriteFile(path, []byte("[binds]\n'Cmd+Return'='none'\n'Shift+Cmd+Left'='quit'\n'Alt+X'='spawn-terminal'\n"), 0600); err != nil {
66+ t.Fatal(err)
67+ }
68+ c, err := Load(path)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if _, ok := c.Binds["Cmd+Return"]; ok {
73+ t.Fatal(c.Binds)
74+ }
75+ if _, ok := c.Binds["Cmd+Shift+Left"]; ok {
76+ t.Fatal(c.Binds)
77+ }
78+ if c.Binds["Shift+Cmd+Left"] != "quit" || c.Binds["Alt+X"] != "spawn-terminal" {
79+ t.Fatal(c.Binds)
80+ }
81+}
M internal/app/app.go +2 -1
@@ -6,11 +6,12 @@ import (
66 "time"
77
88 "github.com/bnema/nefertty/internal/logging"
9+ "github.com/bnema/nefertty/internal/ports"
910 )
1011
1112 type Options struct {
1213 Backend string
13- Config string
14+ Config ports.Config
1415 Timeout time.Duration
1516 }
1617
@@ -6,11 +6,12 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/nefertty/internal/logging"8 "github.com/bnema/nefertty/internal/logging"
9+ "github.com/bnema/nefertty/internal/ports"
9 )10 )
10 11
11 type Options struct {12 type Options struct {
12 Backend string13 Backend string
13- Config string14+ Config ports.Config
14 Timeout time.Duration15 Timeout time.Duration
15 }16 }
16 17
A internal/ports/config.go +26 -0
@@ -0,0 +1,26 @@
1+package ports
2+
3+// Config is the parsed compositor configuration.
4+type Config struct {
5+ Keyboard struct {
6+ Layout, Variant, Options string
7+ RepeatRate int `toml:"repeat_rate"`
8+ RepeatDelay int `toml:"repeat_delay"`
9+ CmdKey string `toml:"cmd_key"`
10+ } `toml:"keyboard"`
11+ Terminal struct{ Command []string } `toml:"terminal"`
12+ Background struct{ Color string } `toml:"background"`
13+ Layout struct {
14+ Gaps int
15+ DefaultColumnWidth string `toml:"default_column_width"`
16+ Presets []string
17+ } `toml:"layout"`
18+ Binds map[string]string `toml:"binds"`
19+ Render struct {
20+ DirectScanout bool `toml:"direct_scanout"`
21+ } `toml:"render"`
22+ Log struct {
23+ Level string
24+ Debug []string
25+ } `toml:"log"`
26+}
@@ -0,0 +1,26 @@
1+package ports
2+
3+// Config is the parsed compositor configuration.
4+type Config struct {
5+ Keyboard struct {
6+ Layout, Variant, Options string
7+ RepeatRate int `toml:"repeat_rate"`
8+ RepeatDelay int `toml:"repeat_delay"`
9+ CmdKey string `toml:"cmd_key"`
10+ } `toml:"keyboard"`
11+ Terminal struct{ Command []string } `toml:"terminal"`
12+ Background struct{ Color string } `toml:"background"`
13+ Layout struct {
14+ Gaps int
15+ DefaultColumnWidth string `toml:"default_column_width"`
16+ Presets []string
17+ } `toml:"layout"`
18+ Binds map[string]string `toml:"binds"`
19+ Render struct {
20+ DirectScanout bool `toml:"direct_scanout"`
21+ } `toml:"render"`
22+ Log struct {
23+ Level string
24+ Debug []string
25+ } `toml:"log"`
26+}

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 23

Tags 10

↑↓ moveenter openesc close