detached 77b3aae

fix(config): keep saved scale in its config group (#28)

commit
77b3aaec09d3764c427d028d6c2d1a8af649aa0b
author
bnema · · 2026-09-27 16:36 UTC
committer
GitHub · · 2026-09-27 16:36 UTC
parent
089984d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +85 -18
M internal/adapters/config/scalestore.go +68 -15
@@ -11,13 +11,12 @@ import (
1111 "strings"
1212 )
1313
14-// ScaleStore writes output.<name>.scale into the config file. It edits the
15-// value in place and keeps every other line, comments included.
14+// ScaleStore writes output.<name>.scale into the config file, preserving
15+// unrelated lines and comments.
1616 type ScaleStore struct{ Path string }
1717
18-// SaveOutputScale sets output.<name>.scale, appending the key when the file
19-// has none. The file is replaced atomically; a symlinked config is written
20-// through to its target.
18+// SaveOutputScale updates output.<name>.scale in the config file. The file is
19+// replaced atomically; a symlinked config is written through to its target.
2120 func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
2221 path := s.Path
2322 if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
@@ -44,21 +43,66 @@ func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
4443 return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
4544 }
4645
47-// setKey replaces the value of the last line setting key (the one parse
48-// keeps) or appends key = value.
46+// setKey replaces the last active assignment, or enables a matching commented
47+// assignment. New keys go beside the closest related key, then at the end.
4948 func setKey(data []byte, key, value string) []byte {
5049 lines := strings.SplitAfter(string(data), "\n")
50+ commented := -1
51+ for i, line := range lines {
52+ body := strings.TrimSpace(line)
53+ if !strings.HasPrefix(body, "#") {
54+ continue
55+ }
56+ body = strings.TrimSpace(strings.TrimPrefix(body, "#"))
57+ k, _, ok := strings.Cut(body, "=")
58+ if ok && strings.TrimSpace(k) == key {
59+ commented = i
60+ }
61+ }
5162 for i := len(lines) - 1; i >= 0; i-- {
52- k, v, ok := strings.Cut(lines[i], "=")
53- if !ok || strings.TrimSpace(k) != key {
63+ prefix, rest, ok := strings.Cut(lines[i], "=")
64+ if !ok || strings.TrimSpace(prefix) != key {
65+ continue
66+ }
67+ lines[i] = replaceValue(prefix+"=", rest, value)
68+ return []byte(strings.Join(lines, ""))
69+ }
70+ if commented >= 0 {
71+ line := lines[commented]
72+ indent, body, _ := strings.Cut(line, "#")
73+ prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
74+ lines[commented] = replaceValue(indent+prefix+"=", rest, value)
75+ return []byte(strings.Join(lines, ""))
76+ }
77+ // Match the longest dotted parent first: output.DP-2.scale belongs beside
78+ // output.DP-2.*, not merely beside any output.* key.
79+ for parent := key; ; {
80+ dot := strings.LastIndexByte(parent, '.')
81+ if dot < 0 {
82+ break
83+ }
84+ parent = parent[:dot]
85+ index := -1
86+ for i, line := range lines {
87+ k, _, ok := strings.Cut(line, "=")
88+ k = strings.TrimSpace(k)
89+ if ok && (k == parent || strings.HasPrefix(k, parent+".")) {
90+ index = i
91+ }
92+ }
93+ if index < 0 {
5494 continue
5595 }
56- // Keep the spacing around = and an end-of-line comment.
57- rest := strings.TrimRight(v, "\r\n")
58- eol := v[len(rest):]
59- old := stripComment(strings.TrimSpace(rest))
60- start := strings.Index(rest, old)
61- lines[i] = k + "=" + rest[:start] + value + rest[start+len(old):] + eol
96+ eol := "\n"
97+ if strings.HasSuffix(lines[index], "\r\n") {
98+ eol = "\r\n"
99+ }
100+ if !strings.HasSuffix(lines[index], "\n") {
101+ lines[index] += eol
102+ }
103+ lines = append(lines, "")
104+ copy(lines[index+2:], lines[index+1:])
105+ lines[index+1] = key + " = " + value + eol
62106 return []byte(strings.Join(lines, ""))
63107 }
64108 var b bytes.Buffer
@@ -70,6 +114,15 @@ func setKey(data []byte, key, value string) []byte {
70114 return b.Bytes()
71115 }
72116
117+// replaceValue keeps indentation, spacing and trailing comments in an assignment.
118+func replaceValue(prefix, rest, value string) string {
119+ plain := strings.TrimRight(rest, "\r\n")
120+ eol := rest[len(plain):]
121+ old := stripComment(strings.TrimSpace(plain))
122+ start := strings.Index(plain, old)
123+ return prefix + plain[:start] + value + plain[start+len(old):] + eol
124+}
125+
73126 // formatScale writes scales as the config expects them: a decimal when it is
74127 // exact (1.25), else a fraction (4/3).
75128 func formatScale(s float64) string {
@@ -11,13 +11,12 @@ import (
11 "strings"11 "strings"
12 )12 )
13 13
14-// ScaleStore writes output.<name>.scale into the config file. It edits the14+// ScaleStore writes output.<name>.scale into the config file, preserving
15-// value in place and keeps every other line, comments included.15+// unrelated lines and comments.
16 type ScaleStore struct{ Path string }16 type ScaleStore struct{ Path string }
17 17
18-// SaveOutputScale sets output.<name>.scale, appending the key when the file18+// SaveOutputScale updates output.<name>.scale in the config file. The file is
19-// has none. The file is replaced atomically; a symlinked config is written19+// replaced atomically; a symlinked config is written through to its target.
20-// through to its target.
21 func (s ScaleStore) SaveOutputScale(output string, scale float64) error {20 func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
22 path := s.Path21 path := s.Path
23 if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {22 if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
@@ -44,21 +43,66 @@ func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
44 return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)43 return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
45 }44 }
46 45
47-// setKey replaces the value of the last line setting key (the one parse46+// setKey replaces the last active assignment, or enables a matching commented
48-// keeps) or appends key = value.47+// assignment. New keys go beside the closest related key, then at the end.
49 func setKey(data []byte, key, value string) []byte {48 func setKey(data []byte, key, value string) []byte {
50 lines := strings.SplitAfter(string(data), "\n")49 lines := strings.SplitAfter(string(data), "\n")
50+ commented := -1
51+ for i, line := range lines {
52+ body := strings.TrimSpace(line)
53+ if !strings.HasPrefix(body, "#") {
54+ continue
55+ }
56+ body = strings.TrimSpace(strings.TrimPrefix(body, "#"))
57+ k, _, ok := strings.Cut(body, "=")
58+ if ok && strings.TrimSpace(k) == key {
59+ commented = i
60+ }
61+ }
51 for i := len(lines) - 1; i >= 0; i-- {62 for i := len(lines) - 1; i >= 0; i-- {
52- k, v, ok := strings.Cut(lines[i], "=")63+ prefix, rest, ok := strings.Cut(lines[i], "=")
53- if !ok || strings.TrimSpace(k) != key {64+ if !ok || strings.TrimSpace(prefix) != key {
65+ continue
66+ }
67+ lines[i] = replaceValue(prefix+"=", rest, value)
68+ return []byte(strings.Join(lines, ""))
69+ }
70+ if commented >= 0 {
71+ line := lines[commented]
72+ indent, body, _ := strings.Cut(line, "#")
73+ prefix, rest, _ := strings.Cut(strings.TrimLeft(body, " \t"), "=")
74+ lines[commented] = replaceValue(indent+prefix+"=", rest, value)
75+ return []byte(strings.Join(lines, ""))
76+ }
77+ // Match the longest dotted parent first: output.DP-2.scale belongs beside
78+ // output.DP-2.*, not merely beside any output.* key.
79+ for parent := key; ; {
80+ dot := strings.LastIndexByte(parent, '.')
81+ if dot < 0 {
82+ break
83+ }
84+ parent = parent[:dot]
85+ index := -1
86+ for i, line := range lines {
87+ k, _, ok := strings.Cut(line, "=")
88+ k = strings.TrimSpace(k)
89+ if ok && (k == parent || strings.HasPrefix(k, parent+".")) {
90+ index = i
91+ }
92+ }
93+ if index < 0 {
54 continue94 continue
55 }95 }
56- // Keep the spacing around = and an end-of-line comment.96+ eol := "\n"
57- rest := strings.TrimRight(v, "\r\n")97+ if strings.HasSuffix(lines[index], "\r\n") {
58- eol := v[len(rest):]98+ eol = "\r\n"
59- old := stripComment(strings.TrimSpace(rest))99+ }
60- start := strings.Index(rest, old)100+ if !strings.HasSuffix(lines[index], "\n") {
61- lines[i] = k + "=" + rest[:start] + value + rest[start+len(old):] + eol101+ lines[index] += eol
102+ }
103+ lines = append(lines, "")
104+ copy(lines[index+2:], lines[index+1:])
105+ lines[index+1] = key + " = " + value + eol
62 return []byte(strings.Join(lines, ""))106 return []byte(strings.Join(lines, ""))
63 }107 }
64 var b bytes.Buffer108 var b bytes.Buffer
@@ -70,6 +114,15 @@ func setKey(data []byte, key, value string) []byte {
70 return b.Bytes()114 return b.Bytes()
71 }115 }
72 116
117+// replaceValue keeps indentation, spacing and trailing comments in an assignment.
118+func replaceValue(prefix, rest, value string) string {
119+ plain := strings.TrimRight(rest, "\r\n")
120+ eol := rest[len(plain):]
121+ old := stripComment(strings.TrimSpace(plain))
122+ start := strings.Index(plain, old)
123+ return prefix + plain[:start] + value + plain[start+len(old):] + eol
124+}
125+
73 // formatScale writes scales as the config expects them: a decimal when it is126 // formatScale writes scales as the config expects them: a decimal when it is
74 // exact (1.25), else a fraction (4/3).127 // exact (1.25), else a fraction (4/3).
75 func formatScale(s float64) string {128 func formatScale(s float64) string {
M internal/adapters/config/scalestore_test.go +17 -3
@@ -15,11 +15,18 @@ func TestSaveOutputScale(t *testing.T) {
1515 {"missing file", "", "output.DP-2.scale = 1.5\n", 1.5},
1616 {"append", "# my config\nborder.width = 3", "# my config\nborder.width = 3\noutput.DP-2.scale = 1.5\n", 1.5},
1717 {"replace in place", "output.DP-2.scale = 2 # big\n# end\n", "output.DP-2.scale = 1.25 # big\n# end\n", 1.25},
18+ {"enable commented value", "# output.DP-2.scale = 4/3 # 4K display\nrender.vrr = on\n", "output.DP-2.scale = 1.5 # 4K display\nrender.vrr = on\n", 1.5},
19+ {"enable indented CRLF example", " # output.DP-2.scale = 2 # note\r\nrender.vrr = on\r\n", " output.DP-2.scale = 1.5 # note\r\nrender.vrr = on\r\n", 1.5},
20+ {"insert with same output group", "output.DP-2.hdr = off\noutput.DP-1 = off\n", "output.DP-2.hdr = off\noutput.DP-2.scale = 1.5\noutput.DP-1 = off\n", 1.5},
21+ {"insert with output group", "output.DP-1 = off\nrender.vrr = on\n", "output.DP-1 = off\noutput.DP-2.scale = 1.5\nrender.vrr = on\n", 1.5},
22+ {"enable example before bind", "# Scale setting\n# output.DP-2.scale = 4/3\n\nbind.cmd+return = spawn-terminal\n", "# Scale setting\noutput.DP-2.scale = 1.25\n\nbind.cmd+return = spawn-terminal\n", 1.25},
23+ {"insert beside output", "output.DP-2 = preferred\noutput.DP-1 = off\nrender.vrr = on\n", "output.DP-2 = preferred\noutput.DP-2.scale = 1.5\noutput.DP-1 = off\nrender.vrr = on\n", 1.5},
24+ {"active beats commented example", "output.DP-2.scale = 2\n# output.DP-2.scale = 4/3\n", "output.DP-2.scale = 1.5\n# output.DP-2.scale = 4/3\n", 1.5},
1825 {"keeps CRLF", "output.DP-2.scale = 2\r\nborder.width = 3\r\n", "output.DP-2.scale = 1.5\r\nborder.width = 3\r\n", 1.5},
1926 {"comment with = and #", "output.DP-2.scale = 2 # was a=b #1\n", "output.DP-2.scale = 1.5 # was a=b #1\n", 1.5},
2027 {"keeps spacing", "output.DP-2.scale=2\n", "output.DP-2.scale=4/3\n", 4.0 / 3},
2128 {"last line wins", "output.DP-2.scale = 2\noutput.DP-2.scale = 3\n", "output.DP-2.scale = 2\noutput.DP-2.scale = 5/3\n", 5.0 / 3},
22- {"ignores comments and other outputs", "# output.DP-2.scale = 2\noutput.DP-1.scale = 2\n", "# output.DP-2.scale = 2\noutput.DP-1.scale = 2\noutput.DP-2.scale = 1\n", 1},
29+ {"ignores other outputs", "# output.DP-1.scale = 2\noutput.DP-1.scale = 2\n", "# output.DP-1.scale = 2\noutput.DP-1.scale = 2\noutput.DP-2.scale = 1\n", 1},
2330 } {
2431 t.Run(tc.name, func(t *testing.T) {
2532 path := filepath.Join(t.TempDir(), "neferwl", "config")
@@ -46,11 +53,18 @@ func TestSaveOutputScale(t *testing.T) {
4653 if err != nil {
4754 t.Fatal(err)
4855 }
56+ found := false
4957 for _, o := range cfg.Outputs {
50- if o.Name == "DP-2" && o.Scale != tc.scale {
51- t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)
58+ if o.Name == "DP-2" {
59+ found = true
60+ if o.Scale != tc.scale {
61+ t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)
62+ }
5263 }
5364 }
65+ if !found {
66+ t.Fatal("saved output missing from parsed config")
67+ }
5468 // Existing files keep their mode; new ones are private.
5569 {
5670 if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
@@ -15,11 +15,18 @@ func TestSaveOutputScale(t *testing.T) {
15 {"missing file", "", "output.DP-2.scale = 1.5\n", 1.5},15 {"missing file", "", "output.DP-2.scale = 1.5\n", 1.5},
16 {"append", "# my config\nborder.width = 3", "# my config\nborder.width = 3\noutput.DP-2.scale = 1.5\n", 1.5},16 {"append", "# my config\nborder.width = 3", "# my config\nborder.width = 3\noutput.DP-2.scale = 1.5\n", 1.5},
17 {"replace in place", "output.DP-2.scale = 2 # big\n# end\n", "output.DP-2.scale = 1.25 # big\n# end\n", 1.25},17 {"replace in place", "output.DP-2.scale = 2 # big\n# end\n", "output.DP-2.scale = 1.25 # big\n# end\n", 1.25},
18+ {"enable commented value", "# output.DP-2.scale = 4/3 # 4K display\nrender.vrr = on\n", "output.DP-2.scale = 1.5 # 4K display\nrender.vrr = on\n", 1.5},
19+ {"enable indented CRLF example", " # output.DP-2.scale = 2 # note\r\nrender.vrr = on\r\n", " output.DP-2.scale = 1.5 # note\r\nrender.vrr = on\r\n", 1.5},
20+ {"insert with same output group", "output.DP-2.hdr = off\noutput.DP-1 = off\n", "output.DP-2.hdr = off\noutput.DP-2.scale = 1.5\noutput.DP-1 = off\n", 1.5},
21+ {"insert with output group", "output.DP-1 = off\nrender.vrr = on\n", "output.DP-1 = off\noutput.DP-2.scale = 1.5\nrender.vrr = on\n", 1.5},
22+ {"enable example before bind", "# Scale setting\n# output.DP-2.scale = 4/3\n\nbind.cmd+return = spawn-terminal\n", "# Scale setting\noutput.DP-2.scale = 1.25\n\nbind.cmd+return = spawn-terminal\n", 1.25},
23+ {"insert beside output", "output.DP-2 = preferred\noutput.DP-1 = off\nrender.vrr = on\n", "output.DP-2 = preferred\noutput.DP-2.scale = 1.5\noutput.DP-1 = off\nrender.vrr = on\n", 1.5},
24+ {"active beats commented example", "output.DP-2.scale = 2\n# output.DP-2.scale = 4/3\n", "output.DP-2.scale = 1.5\n# output.DP-2.scale = 4/3\n", 1.5},
18 {"keeps CRLF", "output.DP-2.scale = 2\r\nborder.width = 3\r\n", "output.DP-2.scale = 1.5\r\nborder.width = 3\r\n", 1.5},25 {"keeps CRLF", "output.DP-2.scale = 2\r\nborder.width = 3\r\n", "output.DP-2.scale = 1.5\r\nborder.width = 3\r\n", 1.5},
19 {"comment with = and #", "output.DP-2.scale = 2 # was a=b #1\n", "output.DP-2.scale = 1.5 # was a=b #1\n", 1.5},26 {"comment with = and #", "output.DP-2.scale = 2 # was a=b #1\n", "output.DP-2.scale = 1.5 # was a=b #1\n", 1.5},
20 {"keeps spacing", "output.DP-2.scale=2\n", "output.DP-2.scale=4/3\n", 4.0 / 3},27 {"keeps spacing", "output.DP-2.scale=2\n", "output.DP-2.scale=4/3\n", 4.0 / 3},
21 {"last line wins", "output.DP-2.scale = 2\noutput.DP-2.scale = 3\n", "output.DP-2.scale = 2\noutput.DP-2.scale = 5/3\n", 5.0 / 3},28 {"last line wins", "output.DP-2.scale = 2\noutput.DP-2.scale = 3\n", "output.DP-2.scale = 2\noutput.DP-2.scale = 5/3\n", 5.0 / 3},
22- {"ignores comments and other outputs", "# output.DP-2.scale = 2\noutput.DP-1.scale = 2\n", "# output.DP-2.scale = 2\noutput.DP-1.scale = 2\noutput.DP-2.scale = 1\n", 1},29+ {"ignores other outputs", "# output.DP-1.scale = 2\noutput.DP-1.scale = 2\n", "# output.DP-1.scale = 2\noutput.DP-1.scale = 2\noutput.DP-2.scale = 1\n", 1},
23 } {30 } {
24 t.Run(tc.name, func(t *testing.T) {31 t.Run(tc.name, func(t *testing.T) {
25 path := filepath.Join(t.TempDir(), "neferwl", "config")32 path := filepath.Join(t.TempDir(), "neferwl", "config")
@@ -46,11 +53,18 @@ func TestSaveOutputScale(t *testing.T) {
46 if err != nil {53 if err != nil {
47 t.Fatal(err)54 t.Fatal(err)
48 }55 }
56+ found := false
49 for _, o := range cfg.Outputs {57 for _, o := range cfg.Outputs {
50- if o.Name == "DP-2" && o.Scale != tc.scale {58+ if o.Name == "DP-2" {
51- t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)59+ found = true
60+ if o.Scale != tc.scale {
61+ t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)
62+ }
52 }63 }
53 }64 }
65+ if !found {
66+ t.Fatal("saved output missing from parsed config")
67+ }
54 // Existing files keep their mode; new ones are private.68 // Existing files keep their mode; new ones are private.
55 {69 {
56 if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {70 if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {

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