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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 7 | 7 | Build: `make build` | |
| 8 | 8 | ||
| 9 | 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. | |
| @@ -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 ( | |||
| 9 | 9 | "runtime/debug" | |
| 10 | 10 | "syscall" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 12 | 13 | "github.com/bnema/nefertty/internal/app" | |
| 13 | 14 | "github.com/bnema/nefertty/internal/logging" | |
| 14 | 15 | ) | |
| @@ -20,6 +21,23 @@ func main() { | |||
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 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 | 41 | if len(os.Args) == 2 && os.Args[1] == "version" { | |
| 24 | 42 | info, ok := debug.ReadBuildInfo() | |
| 25 | 43 | if !ok { | |
| @@ -37,7 +55,7 @@ func run() error { | |||
| 37 | 55 | backend := flags.String("backend", "drm", "drm or headless") | |
| 38 | 56 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 39 | 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 | 59 | if err := flags.Parse(os.Args[1:]); err != nil { | |
| 42 | 60 | return err | |
| 43 | 61 | } | |
| @@ -65,7 +83,18 @@ func run() error { | |||
| 65 | 83 | log.Error().Err(err).Msg("invalid options") | |
| 66 | 84 | 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 | 98 | log.Error().Err(err).Msg("application failed") | |
| 70 | 99 | return err | |
| 71 | 100 | } | |
| @@ -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 err | 60 | 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 err | 84 | 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 err | 99 | 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 ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | require ( | |
| 12 | + | github.com/BurntSushi/toml v1.5.0 | |
| 12 | 13 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 13 | 14 | github.com/mattn/go-isatty v0.0.22 // indirect | |
| 14 | 15 | 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 // indirect | 13 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 13 | github.com/mattn/go-isatty v0.0.22 // indirect | 14 | github.com/mattn/go-isatty v0.0.22 // indirect | ||
| 14 | golang.org/x/sys v0.48.0 // indirect | 15 | golang.org/x/sys v0.48.0 // indirect | ||
M go.sum +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= | |
| 2 | + | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | |
| 1 | 3 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
| 2 | 4 | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | |
| 3 | 5 | github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | |
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= | |||
| 2 | + | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= | |||
| 1 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | 3 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | ||
| 2 | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | 4 | github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg= | ||
| 3 | github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | 5 | github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= | ||
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 ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/nefertty/internal/logging" | |
| 9 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | type Options struct { | |
| 12 | 13 | Backend string | |
| 13 | - | Config string | |
| 14 | + | Config ports.Config | |
| 14 | 15 | Timeout time.Duration | |
| 15 | 16 | } | |
| 16 | 17 | ||
| @@ -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 string | 13 | Backend string | ||
| 13 | - | Config string | 14 | + | Config ports.Config |
| 14 | Timeout time.Duration | 15 | 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 | + | } | |||