detached 7fe026f

feat(config): add palette anchor setting

commit
7fe026fcd8a58541bff2229ae674e1fec976e0ae
author
brice · · 2026-07-11 06:37 UTC
parent
c15d164
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +97 -4
M internal/adapters/config/config.go +17 -4
@@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3131 var warnings []domain.Warning
3232 seenBindingKeys := make(map[string]bool)
3333 seenFloatingKeys := make(map[string]bool)
34+ seenPaletteKeys := make(map[string]bool)
3435
3536 scanner := bufio.NewScanner(r)
3637 lineNo := 0
@@ -90,8 +91,20 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
9091 interval = domain.MinBarInterval
9192 }
9293 cfg.Bar.Interval = interval
94+ case key == "palette.anchor":
95+ warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
96+ if strings.EqualFold(strings.TrimSpace(value), "auto") {
97+ cfg.Palette = domain.PaletteConfig{}
98+ continue
99+ }
100+ anchor, ok := domain.ParseAnchor(value)
101+ if !ok {
102+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
103+ continue
104+ }
105+ cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
93106 case key == "floating.command":
94- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
107+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
95108 command, ok := parseBarCommand(value)
96109 if !ok {
97110 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
@@ -99,7 +112,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
99112 }
100113 cfg.Floating.Command = command
101114 case key == "floating.width":
102- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
115+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
103116 width, ok := parsePercentage(value)
104117 if !ok {
105118 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
@@ -107,7 +120,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
107120 }
108121 cfg.Floating.Width = width
109122 case key == "floating.height":
110- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
123+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
111124 height, ok := parsePercentage(value)
112125 if !ok {
113126 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
@@ -217,7 +230,7 @@ func fileStamp(path string) (stamp, error) {
217230 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
218231 }
219232
220-func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
233+func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221234 if seen[key] {
222235 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223236 }
@@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
31 var warnings []domain.Warning31 var warnings []domain.Warning
32 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33 seenFloatingKeys := make(map[string]bool)33 seenFloatingKeys := make(map[string]bool)
34+ seenPaletteKeys := make(map[string]bool)
34 35
35 scanner := bufio.NewScanner(r)36 scanner := bufio.NewScanner(r)
36 lineNo := 037 lineNo := 0
@@ -90,8 +91,20 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
90 interval = domain.MinBarInterval91 interval = domain.MinBarInterval
91 }92 }
92 cfg.Bar.Interval = interval93 cfg.Bar.Interval = interval
94+ case key == "palette.anchor":
95+ warnings = warnDuplicateKey(warnings, seenPaletteKeys, key, lineNo)
96+ if strings.EqualFold(strings.TrimSpace(value), "auto") {
97+ cfg.Palette = domain.PaletteConfig{}
98+ continue
99+ }
100+ anchor, ok := domain.ParseAnchor(value)
101+ if !ok {
102+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid palette.anchor %q", value)})
103+ continue
104+ }
105+ cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
93 case key == "floating.command":106 case key == "floating.command":
94- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)107+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
95 command, ok := parseBarCommand(value)108 command, ok := parseBarCommand(value)
96 if !ok {109 if !ok {
97 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})110 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
@@ -99,7 +112,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
99 }112 }
100 cfg.Floating.Command = command113 cfg.Floating.Command = command
101 case key == "floating.width":114 case key == "floating.width":
102- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)115+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
103 width, ok := parsePercentage(value)116 width, ok := parsePercentage(value)
104 if !ok {117 if !ok {
105 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})118 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
@@ -107,7 +120,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
107 }120 }
108 cfg.Floating.Width = width121 cfg.Floating.Width = width
109 case key == "floating.height":122 case key == "floating.height":
110- warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)123+ warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
111 height, ok := parsePercentage(value)124 height, ok := parsePercentage(value)
112 if !ok {125 if !ok {
113 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})126 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
@@ -217,7 +230,7 @@ func fileStamp(path string) (stamp, error) {
217 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil230 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
218 }231 }
219 232
220-func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {233+func warnDuplicateKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221 if seen[key] {234 if seen[key] {
222 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})235 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223 }236 }
A internal/adapters/config/palette_anchor_test.go +72 -0
@@ -0,0 +1,72 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestParsePaletteAnchor(t *testing.T) {
16+ t.Parallel()
17+
18+ tests := []struct {
19+ name string
20+ input string
21+ want domain.PaletteConfig
22+ warnings []domain.Warning
23+ }{
24+ {name: "default is auto", want: domain.PaletteConfig{}},
25+ {name: "explicit auto is case insensitive", input: "palette.anchor = AuTo\n", want: domain.PaletteConfig{}},
26+ {name: "concrete anchors are trimmed and case insensitive", input: "palette.anchor = CENTER\npalette.anchor = top-left\npalette.anchor = Top\npalette.anchor = TOP-RIGHT\npalette.anchor = left\npalette.anchor = RIGHT\npalette.anchor = bottom-left\npalette.anchor = Bottom\npalette.anchor = BOTTOM-RIGHT \n", want: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 3, Msg: "duplicate key \"palette.anchor\""}, {Line: 4, Msg: "duplicate key \"palette.anchor\""}, {Line: 5, Msg: "duplicate key \"palette.anchor\""}, {Line: 6, Msg: "duplicate key \"palette.anchor\""}, {Line: 7, Msg: "duplicate key \"palette.anchor\""}, {Line: 8, Msg: "duplicate key \"palette.anchor\""}, {Line: 9, Msg: "duplicate key \"palette.anchor\""}}},
27+ {name: "invalid first preserves auto", input: "palette.anchor = diagonal\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 1, Msg: "invalid palette.anchor \"diagonal\""}}},
28+ {name: "valid then invalid preserves valid", input: "palette.anchor = top\npalette.anchor = diagonal\n", want: domain.PaletteConfig{Anchor: domain.AnchorTop, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 2, Msg: "invalid palette.anchor \"diagonal\""}}},
29+ {name: "explicit then auto clears to auto", input: "palette.anchor = left\npalette.anchor = auto\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
34+ require.NoError(t, err)
35+ require.Equal(t, tt.want, cfg.Palette)
36+ require.Equal(t, tt.warnings, warnings)
37+ require.Empty(t, cfg.BindingEntries)
38+ })
39+ }
40+}
41+
42+func TestLoadMissingPaletteAnchorDefaultsToAuto(t *testing.T) {
43+ t.Parallel()
44+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
45+ require.NoError(t, err)
46+ require.Empty(t, warnings)
47+ require.Equal(t, domain.PaletteConfig{}, cfg.Palette)
48+}
49+
50+func TestWatchPaletteAnchorRestoresAutoAfterFileRemoval(t *testing.T) {
51+ path := filepath.Join(t.TempDir(), "config")
52+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
53+ clk := newFakeClock(time.Unix(0, 0))
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ changes := make(chan domain.Config, 2)
57+ done := make(chan error, 1)
58+ go func() { done <- Watch(ctx, clk, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) }()
59+ clk.waitForTimers(t, 1)
60+
61+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
62+ clk.advance(2 * time.Second)
63+ require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
64+
65+ require.NoError(t, os.Remove(path))
66+ clk.waitForTimers(t, 1)
67+ clk.advance(2 * time.Second)
68+ got := <-changes
69+ require.Equal(t, domain.PaletteConfig{}, got.Palette)
70+ cancel()
71+ require.ErrorIs(t, <-done, context.Canceled)
72+}
@@ -0,0 +1,72 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestParsePaletteAnchor(t *testing.T) {
16+ t.Parallel()
17+
18+ tests := []struct {
19+ name string
20+ input string
21+ want domain.PaletteConfig
22+ warnings []domain.Warning
23+ }{
24+ {name: "default is auto", want: domain.PaletteConfig{}},
25+ {name: "explicit auto is case insensitive", input: "palette.anchor = AuTo\n", want: domain.PaletteConfig{}},
26+ {name: "concrete anchors are trimmed and case insensitive", input: "palette.anchor = CENTER\npalette.anchor = top-left\npalette.anchor = Top\npalette.anchor = TOP-RIGHT\npalette.anchor = left\npalette.anchor = RIGHT\npalette.anchor = bottom-left\npalette.anchor = Bottom\npalette.anchor = BOTTOM-RIGHT \n", want: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 3, Msg: "duplicate key \"palette.anchor\""}, {Line: 4, Msg: "duplicate key \"palette.anchor\""}, {Line: 5, Msg: "duplicate key \"palette.anchor\""}, {Line: 6, Msg: "duplicate key \"palette.anchor\""}, {Line: 7, Msg: "duplicate key \"palette.anchor\""}, {Line: 8, Msg: "duplicate key \"palette.anchor\""}, {Line: 9, Msg: "duplicate key \"palette.anchor\""}}},
27+ {name: "invalid first preserves auto", input: "palette.anchor = diagonal\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 1, Msg: "invalid palette.anchor \"diagonal\""}}},
28+ {name: "valid then invalid preserves valid", input: "palette.anchor = top\npalette.anchor = diagonal\n", want: domain.PaletteConfig{Anchor: domain.AnchorTop, AnchorSet: true}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}, {Line: 2, Msg: "invalid palette.anchor \"diagonal\""}}},
29+ {name: "explicit then auto clears to auto", input: "palette.anchor = left\npalette.anchor = auto\n", want: domain.PaletteConfig{}, warnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"palette.anchor\""}}},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
34+ require.NoError(t, err)
35+ require.Equal(t, tt.want, cfg.Palette)
36+ require.Equal(t, tt.warnings, warnings)
37+ require.Empty(t, cfg.BindingEntries)
38+ })
39+ }
40+}
41+
42+func TestLoadMissingPaletteAnchorDefaultsToAuto(t *testing.T) {
43+ t.Parallel()
44+ cfg, warnings, err := Load(filepath.Join(t.TempDir(), "missing"))
45+ require.NoError(t, err)
46+ require.Empty(t, warnings)
47+ require.Equal(t, domain.PaletteConfig{}, cfg.Palette)
48+}
49+
50+func TestWatchPaletteAnchorRestoresAutoAfterFileRemoval(t *testing.T) {
51+ path := filepath.Join(t.TempDir(), "config")
52+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = top-right\n"), 0o600))
53+ clk := newFakeClock(time.Unix(0, 0))
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ changes := make(chan domain.Config, 2)
57+ done := make(chan error, 1)
58+ go func() { done <- Watch(ctx, clk, path, func(cfg domain.Config, _ []domain.Warning) { changes <- cfg }) }()
59+ clk.waitForTimers(t, 1)
60+
61+ require.NoError(t, os.WriteFile(path, []byte("palette.anchor = bottom-left\n"), 0o600))
62+ clk.advance(2 * time.Second)
63+ require.Equal(t, domain.AnchorBottomLeft, (<-changes).Palette.Anchor)
64+
65+ require.NoError(t, os.Remove(path))
66+ clk.waitForTimers(t, 1)
67+ clk.advance(2 * time.Second)
68+ got := <-changes
69+ require.Equal(t, domain.PaletteConfig{}, got.Palette)
70+ cancel()
71+ require.ErrorIs(t, <-done, context.Canceled)
72+}
M internal/domain/config.go +8 -0
@@ -47,6 +47,13 @@ type BarConfig struct {
4747 Interval time.Duration
4848 }
4949
50+// PaletteConfig contains palette placement settings. A false AnchorSet uses the
51+// adaptive automatic placement.
52+type PaletteConfig struct {
53+ Anchor Anchor
54+ AnchorSet bool
55+}
56+
5057 // FloatingConfig contains launch and sizing settings for per-tab floating panes.
5158 type FloatingConfig struct {
5259 Command string
@@ -63,6 +70,7 @@ type Config struct {
6370 BindingEntries []ConfigEntry
6471 Codes map[string]string
6572 Snapshot SnapshotConfig
73+ Palette PaletteConfig
6674 Floating FloatingConfig
6775 }
6876
@@ -47,6 +47,13 @@ type BarConfig struct {
47 Interval time.Duration47 Interval time.Duration
48 }48 }
49 49
50+// PaletteConfig contains palette placement settings. A false AnchorSet uses the
51+// adaptive automatic placement.
52+type PaletteConfig struct {
53+ Anchor Anchor
54+ AnchorSet bool
55+}
56+
50 // FloatingConfig contains launch and sizing settings for per-tab floating panes.57 // FloatingConfig contains launch and sizing settings for per-tab floating panes.
51 type FloatingConfig struct {58 type FloatingConfig struct {
52 Command string59 Command string
@@ -63,6 +70,7 @@ type Config struct {
63 BindingEntries []ConfigEntry70 BindingEntries []ConfigEntry
64 Codes map[string]string71 Codes map[string]string
65 Snapshot SnapshotConfig72 Snapshot SnapshotConfig
73+ Palette PaletteConfig
66 Floating FloatingConfig74 Floating FloatingConfig
67 }75 }
68 76

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 13

↑↓ moveenter openesc close