detached 3c68abd

Merge pull request #18: feat(scale): save scale-bind changes to the config with a notification

# Conflicts:
#	internal/app/app.go
#	internal/core/core.go
commit
3c68abd6c33f35400d3759227b847e5b8f6cd66a
author
brice · · 2026-09-27 14:28 UTC
merge of
0049441 + 35f6f59 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +738 -4
M .mockery.yml +2 -0
@@ -13,6 +13,8 @@ packages:
1313 pkgname: portsmocks
1414 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
1515 interfaces:
16+ Notifier:
17+ OutputScaleStore:
1618 Renderer:
1719 Seat:
1820 # Unexported adapter seams: mocks live next to the code, in test files.
@@ -13,6 +13,8 @@ packages:
13 pkgname: portsmocks13 pkgname: portsmocks
14 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'14 filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
15 interfaces:15 interfaces:
16+ Notifier:
17+ OutputScaleStore:
16 Renderer:18 Renderer:
17 Seat:19 Seat:
18 # Unexported adapter seams: mocks live next to the code, in test files.20 # Unexported adapter seams: mocks live next to the code, in test files.
M docs/config.md +1 -1
@@ -86,7 +86,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
8686 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
8787 | `focus-monitor-left/right` | Focus the neighbor monitor |
8888 | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |
89-| `scale-up` / `scale-down` | Zoom the whole display |
89+| `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) |
9090 | `quit` | Exit NeferWL |
9191
9292 ### Default binds
@@ -86,7 +86,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
86 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |86 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
87 | `focus-monitor-left/right` | Focus the neighbor monitor |87 | `focus-monitor-left/right` | Focus the neighbor monitor |
88 | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |88 | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |
89-| `scale-up` / `scale-down` | Zoom the whole display |89+| `scale-up` / `scale-down` | Zoom the whole display; one second after the last press, the scale is saved to `output.<name>.scale` and a notification confirms it (`notify-send`) |
90 | `quit` | Exit NeferWL |90 | `quit` | Exit NeferWL |
91 91
92 ### Default binds92 ### Default binds
M examples/config +2 -1
@@ -66,7 +66,8 @@ focus.follow-move = off
6666 # output.DP-2 = 5120x2160@165.058
6767 # output.HDMI-A-1 = off
6868 # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down
69-# step through the scales where the display divides into whole pixels.
69+# step through the scales where the display divides into whole pixels and
70+# save the result here.
7071 # output.DP-2.scale = 4/3
7172
7273 render.direct-scanout = on
@@ -66,7 +66,8 @@ focus.follow-move = off
66 # output.DP-2 = 5120x2160@165.05866 # output.DP-2 = 5120x2160@165.058
67 # output.HDMI-A-1 = off67 # output.HDMI-A-1 = off
68 # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down68 # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down
69-# step through the scales where the display divides into whole pixels.69+# step through the scales where the display divides into whole pixels and
70+# save the result here.
70 # output.DP-2.scale = 4/371 # output.DP-2.scale = 4/3
71 72
72 render.direct-scanout = on73 render.direct-scanout = on
A internal/adapters/config/scalestore.go +123 -0
@@ -0,0 +1,123 @@
1+package config
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "math"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+)
13+
14+// ScaleStore writes output.<name>.scale into the config file. It edits the
15+// value in place and keeps every other line, comments included.
16+type ScaleStore struct{ Path string }
17+
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.
21+func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
22+ path := s.Path
23+ if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
24+ // Replacing a dangling link would lose it: report it instead.
25+ if path, err = filepath.EvalSymlinks(path); err != nil {
26+ return err
27+ }
28+ }
29+ data, err := os.ReadFile(path)
30+ // A new config may hold commands: only its owner reads it.
31+ mode := os.FileMode(0o600)
32+ switch {
33+ case errors.Is(err, os.ErrNotExist):
34+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
35+ return err
36+ }
37+ case err != nil:
38+ return err
39+ default:
40+ if info, err := os.Stat(path); err == nil {
41+ mode = info.Mode().Perm()
42+ }
43+ }
44+ return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
45+}
46+
47+// setKey replaces the value of the last line setting key (the one parse
48+// keeps) or appends key = value.
49+func setKey(data []byte, key, value string) []byte {
50+ lines := strings.SplitAfter(string(data), "\n")
51+ for i := len(lines) - 1; i >= 0; i-- {
52+ k, v, ok := strings.Cut(lines[i], "=")
53+ if !ok || strings.TrimSpace(k) != key {
54+ continue
55+ }
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
62+ return []byte(strings.Join(lines, ""))
63+ }
64+ var b bytes.Buffer
65+ b.Write(data)
66+ if len(data) > 0 && data[len(data)-1] != '\n' {
67+ b.WriteByte('\n')
68+ }
69+ fmt.Fprintf(&b, "%s = %s\n", key, value)
70+ return b.Bytes()
71+}
72+
73+// formatScale writes scales as the config expects them: a decimal when it is
74+// exact (1.25), else a fraction (4/3).
75+func formatScale(s float64) string {
76+ n, d := int(math.Round(s*120)), 120
77+ g := gcd(n, d)
78+ n, d = n/g, d/g
79+ den := d
80+ for den%2 == 0 {
81+ den /= 2
82+ }
83+ for den%5 == 0 {
84+ den /= 5
85+ }
86+ if den == 1 {
87+ return strconv.FormatFloat(float64(n)/float64(d), 'f', -1, 64)
88+ }
89+ return fmt.Sprintf("%d/%d", n, d)
90+}
91+
92+func gcd(a, b int) int {
93+ for b != 0 {
94+ a, b = b, a%b
95+ }
96+ return a
97+}
98+
99+// writeAtomic replaces path with data: readers and the watcher never see a
100+// half-written file.
101+func writeAtomic(path string, data []byte, mode os.FileMode) error {
102+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
103+ if err != nil {
104+ return err
105+ }
106+ defer os.Remove(tmp.Name())
107+ if _, err := tmp.Write(data); err != nil {
108+ tmp.Close()
109+ return err
110+ }
111+ if err := tmp.Chmod(mode); err != nil {
112+ tmp.Close()
113+ return err
114+ }
115+ if err := tmp.Sync(); err != nil {
116+ tmp.Close()
117+ return err
118+ }
119+ if err := tmp.Close(); err != nil {
120+ return err
121+ }
122+ return os.Rename(tmp.Name(), path)
123+}
@@ -0,0 +1,123 @@
1+package config
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "fmt"
7+ "math"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+)
13+
14+// ScaleStore writes output.<name>.scale into the config file. It edits the
15+// value in place and keeps every other line, comments included.
16+type ScaleStore struct{ Path string }
17+
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.
21+func (s ScaleStore) SaveOutputScale(output string, scale float64) error {
22+ path := s.Path
23+ if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
24+ // Replacing a dangling link would lose it: report it instead.
25+ if path, err = filepath.EvalSymlinks(path); err != nil {
26+ return err
27+ }
28+ }
29+ data, err := os.ReadFile(path)
30+ // A new config may hold commands: only its owner reads it.
31+ mode := os.FileMode(0o600)
32+ switch {
33+ case errors.Is(err, os.ErrNotExist):
34+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
35+ return err
36+ }
37+ case err != nil:
38+ return err
39+ default:
40+ if info, err := os.Stat(path); err == nil {
41+ mode = info.Mode().Perm()
42+ }
43+ }
44+ return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
45+}
46+
47+// setKey replaces the value of the last line setting key (the one parse
48+// keeps) or appends key = value.
49+func setKey(data []byte, key, value string) []byte {
50+ lines := strings.SplitAfter(string(data), "\n")
51+ for i := len(lines) - 1; i >= 0; i-- {
52+ k, v, ok := strings.Cut(lines[i], "=")
53+ if !ok || strings.TrimSpace(k) != key {
54+ continue
55+ }
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
62+ return []byte(strings.Join(lines, ""))
63+ }
64+ var b bytes.Buffer
65+ b.Write(data)
66+ if len(data) > 0 && data[len(data)-1] != '\n' {
67+ b.WriteByte('\n')
68+ }
69+ fmt.Fprintf(&b, "%s = %s\n", key, value)
70+ return b.Bytes()
71+}
72+
73+// formatScale writes scales as the config expects them: a decimal when it is
74+// exact (1.25), else a fraction (4/3).
75+func formatScale(s float64) string {
76+ n, d := int(math.Round(s*120)), 120
77+ g := gcd(n, d)
78+ n, d = n/g, d/g
79+ den := d
80+ for den%2 == 0 {
81+ den /= 2
82+ }
83+ for den%5 == 0 {
84+ den /= 5
85+ }
86+ if den == 1 {
87+ return strconv.FormatFloat(float64(n)/float64(d), 'f', -1, 64)
88+ }
89+ return fmt.Sprintf("%d/%d", n, d)
90+}
91+
92+func gcd(a, b int) int {
93+ for b != 0 {
94+ a, b = b, a%b
95+ }
96+ return a
97+}
98+
99+// writeAtomic replaces path with data: readers and the watcher never see a
100+// half-written file.
101+func writeAtomic(path string, data []byte, mode os.FileMode) error {
102+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
103+ if err != nil {
104+ return err
105+ }
106+ defer os.Remove(tmp.Name())
107+ if _, err := tmp.Write(data); err != nil {
108+ tmp.Close()
109+ return err
110+ }
111+ if err := tmp.Chmod(mode); err != nil {
112+ tmp.Close()
113+ return err
114+ }
115+ if err := tmp.Sync(); err != nil {
116+ tmp.Close()
117+ return err
118+ }
119+ if err := tmp.Close(); err != nil {
120+ return err
121+ }
122+ return os.Rename(tmp.Name(), path)
123+}
A internal/adapters/config/scalestore_test.go +105 -0
@@ -0,0 +1,105 @@
1+package config
2+
3+import (
4+ "math"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestSaveOutputScale(t *testing.T) {
11+ for _, tc := range []struct {
12+ name, in, want string
13+ scale float64
14+ }{
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},
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+ {"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},
20+ {"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},
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},
23+ } {
24+ t.Run(tc.name, func(t *testing.T) {
25+ path := filepath.Join(t.TempDir(), "neferwl", "config")
26+ if tc.in != "" {
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(path, []byte(tc.in), 0o600); err != nil {
31+ t.Fatal(err)
32+ }
33+ }
34+ if err := (ScaleStore{Path: path}).SaveOutputScale("DP-2", tc.scale); err != nil {
35+ t.Fatal(err)
36+ }
37+ got, err := os.ReadFile(path)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if string(got) != tc.want {
42+ t.Fatalf("got\n%s\nwant\n%s", got, tc.want)
43+ }
44+ // The written value parses back to the same scale.
45+ cfg, _, err := Load(path)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ 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)
52+ }
53+ }
54+ // Existing files keep their mode; new ones are private.
55+ {
56+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
57+ t.Fatalf("mode %v", info.Mode().Perm())
58+ }
59+ }
60+ })
61+ }
62+}
63+
64+// A symlinked config (dotfiles) stays a symlink; its target gets the value.
65+func TestSaveOutputScaleSymlink(t *testing.T) {
66+ dir := t.TempDir()
67+ target := filepath.Join(dir, "dotfiles-config")
68+ if err := os.WriteFile(target, []byte("border.width = 3\n"), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+ link := filepath.Join(dir, "config")
72+ if err := os.Symlink(target, link); err != nil {
73+ t.Fatal(err)
74+ }
75+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err != nil {
76+ t.Fatal(err)
77+ }
78+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
79+ t.Fatal("symlink replaced", err)
80+ }
81+ if got, _ := os.ReadFile(target); string(got) != "border.width = 3\noutput.DP-2.scale = 2\n" {
82+ t.Fatalf("%q", got)
83+ }
84+ // A dangling link is an error and stays in place.
85+ if err := os.Remove(target); err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err == nil {
89+ t.Fatal("dangling link saved")
90+ }
91+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
92+ t.Fatal("dangling symlink replaced", err)
93+ }
94+}
95+
96+// Every clean scale is written in a form the parser reads back exactly.
97+func TestFormatScaleRoundTrip(t *testing.T) {
98+ for n := 120; n <= 480; n++ {
99+ s := float64(n) / 120
100+ got, err := parseScale(formatScale(s))
101+ if err != nil || math.Round(got*120) != float64(n) {
102+ t.Fatalf("%d/120: %q parsed as %v, %v", n, formatScale(s), got, err)
103+ }
104+ }
105+}
@@ -0,0 +1,105 @@
1+package config
2+
3+import (
4+ "math"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestSaveOutputScale(t *testing.T) {
11+ for _, tc := range []struct {
12+ name, in, want string
13+ scale float64
14+ }{
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},
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+ {"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},
20+ {"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},
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},
23+ } {
24+ t.Run(tc.name, func(t *testing.T) {
25+ path := filepath.Join(t.TempDir(), "neferwl", "config")
26+ if tc.in != "" {
27+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
28+ t.Fatal(err)
29+ }
30+ if err := os.WriteFile(path, []byte(tc.in), 0o600); err != nil {
31+ t.Fatal(err)
32+ }
33+ }
34+ if err := (ScaleStore{Path: path}).SaveOutputScale("DP-2", tc.scale); err != nil {
35+ t.Fatal(err)
36+ }
37+ got, err := os.ReadFile(path)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if string(got) != tc.want {
42+ t.Fatalf("got\n%s\nwant\n%s", got, tc.want)
43+ }
44+ // The written value parses back to the same scale.
45+ cfg, _, err := Load(path)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ 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)
52+ }
53+ }
54+ // Existing files keep their mode; new ones are private.
55+ {
56+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
57+ t.Fatalf("mode %v", info.Mode().Perm())
58+ }
59+ }
60+ })
61+ }
62+}
63+
64+// A symlinked config (dotfiles) stays a symlink; its target gets the value.
65+func TestSaveOutputScaleSymlink(t *testing.T) {
66+ dir := t.TempDir()
67+ target := filepath.Join(dir, "dotfiles-config")
68+ if err := os.WriteFile(target, []byte("border.width = 3\n"), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+ link := filepath.Join(dir, "config")
72+ if err := os.Symlink(target, link); err != nil {
73+ t.Fatal(err)
74+ }
75+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err != nil {
76+ t.Fatal(err)
77+ }
78+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
79+ t.Fatal("symlink replaced", err)
80+ }
81+ if got, _ := os.ReadFile(target); string(got) != "border.width = 3\noutput.DP-2.scale = 2\n" {
82+ t.Fatalf("%q", got)
83+ }
84+ // A dangling link is an error and stays in place.
85+ if err := os.Remove(target); err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err == nil {
89+ t.Fatal("dangling link saved")
90+ }
91+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
92+ t.Fatal("dangling symlink replaced", err)
93+ }
94+}
95+
96+// Every clean scale is written in a form the parser reads back exactly.
97+func TestFormatScaleRoundTrip(t *testing.T) {
98+ for n := 120; n <= 480; n++ {
99+ s := float64(n) / 120
100+ got, err := parseScale(formatScale(s))
101+ if err != nil || math.Round(got*120) != float64(n) {
102+ t.Fatalf("%d/120: %q parsed as %v, %v", n, formatScale(s), got, err)
103+ }
104+ }
105+}
A internal/adapters/launcher/notify.go +42 -0
@@ -0,0 +1,42 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+)
10+
11+// Notifier shows desktop notifications with notify-send, so any
12+// notification daemon of the session (mako, dunst…) displays them.
13+type Notifier struct {
14+ ctx context.Context
15+ env []string
16+ log zerowrap.Logger
17+}
18+
19+// NewNotifier runs notify-send with the child environment, which carries
20+// the session bus address. Ending ctx kills running notify-send processes.
21+func NewNotifier(ctx context.Context, env []string, log zerowrap.Logger) *Notifier {
22+ return &Notifier{ctx: ctx, env: append([]string(nil), env...), log: log}
23+}
24+
25+// Notify starts notify-send and returns; it is killed after five seconds.
26+// A missing tool or daemon only logs a warning.
27+func (n *Notifier) Notify(summary, body string) {
28+ path, err := childLookPath("notify-send", n.env)
29+ if err != nil {
30+ n.log.Warn().Err(err).Str("summary", summary).Msg("notification skipped: notify-send not found")
31+ return
32+ }
33+ go func() {
34+ ctx, cancel := context.WithTimeout(n.ctx, 5*time.Second)
35+ defer cancel()
36+ cmd := exec.CommandContext(ctx, path, "--app-name=neferwl", "--", summary, body)
37+ cmd.Env = n.env
38+ if out, err := cmd.CombinedOutput(); err != nil && n.ctx.Err() == nil {
39+ n.log.Warn().Err(err).Str("output", string(out)).Str("summary", summary).Msg("notification failed")
40+ }
41+ }()
42+}
@@ -0,0 +1,42 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+)
10+
11+// Notifier shows desktop notifications with notify-send, so any
12+// notification daemon of the session (mako, dunst…) displays them.
13+type Notifier struct {
14+ ctx context.Context
15+ env []string
16+ log zerowrap.Logger
17+}
18+
19+// NewNotifier runs notify-send with the child environment, which carries
20+// the session bus address. Ending ctx kills running notify-send processes.
21+func NewNotifier(ctx context.Context, env []string, log zerowrap.Logger) *Notifier {
22+ return &Notifier{ctx: ctx, env: append([]string(nil), env...), log: log}
23+}
24+
25+// Notify starts notify-send and returns; it is killed after five seconds.
26+// A missing tool or daemon only logs a warning.
27+func (n *Notifier) Notify(summary, body string) {
28+ path, err := childLookPath("notify-send", n.env)
29+ if err != nil {
30+ n.log.Warn().Err(err).Str("summary", summary).Msg("notification skipped: notify-send not found")
31+ return
32+ }
33+ go func() {
34+ ctx, cancel := context.WithTimeout(n.ctx, 5*time.Second)
35+ defer cancel()
36+ cmd := exec.CommandContext(ctx, path, "--app-name=neferwl", "--", summary, body)
37+ cmd.Env = n.env
38+ if out, err := cmd.CombinedOutput(); err != nil && n.ctx.Err() == nil {
39+ n.log.Warn().Err(err).Str("output", string(out)).Str("summary", summary).Msg("notification failed")
40+ }
41+ }()
42+}
A internal/adapters/launcher/notify_test.go +38 -0
@@ -0,0 +1,38 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/logging"
11+)
12+
13+// notify-send runs with the child environment and the summary and body as
14+// separate arguments; the script on PATH records them.
15+func TestNotifier(t *testing.T) {
16+ dir := t.TempDir()
17+ out := filepath.Join(dir, "calls")
18+ script := "#!/bin/sh\nprintf '%s|' \"$@\" \"$DBUS_SESSION_BUS_ADDRESS\" > " + out + ".tmp && mv " + out + ".tmp " + out + "\n"
19+ if err := os.WriteFile(filepath.Join(dir, "notify-send"), []byte(script), 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ ctx := context.Background()
23+ log := logging.For(ctx, "launcher")
24+ NewNotifier(ctx, []string{"PATH=" + dir + ":/usr/bin:/bin", "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/bus"}, log).Notify("DP-2 scale saved", "-Scale 1.5")
25+ // Notify returns at once; wait for the script to record the call.
26+ data, err := os.ReadFile(out)
27+ for deadline := time.Now().Add(5 * time.Second); err != nil; data, err = os.ReadFile(out) {
28+ if time.Now().After(deadline) {
29+ t.Fatal("notify-send not run")
30+ }
31+ time.Sleep(10 * time.Millisecond)
32+ }
33+ if want := "--app-name=neferwl|--|DP-2 scale saved|-Scale 1.5|unix:path=/tmp/bus|"; string(data) != want {
34+ t.Fatalf("got %q, want %q", data, want)
35+ }
36+ // A missing notify-send is skipped, not fatal.
37+ NewNotifier(ctx, []string{"PATH=" + t.TempDir()}, log).Notify("x", "y")
38+}
@@ -0,0 +1,38 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/logging"
11+)
12+
13+// notify-send runs with the child environment and the summary and body as
14+// separate arguments; the script on PATH records them.
15+func TestNotifier(t *testing.T) {
16+ dir := t.TempDir()
17+ out := filepath.Join(dir, "calls")
18+ script := "#!/bin/sh\nprintf '%s|' \"$@\" \"$DBUS_SESSION_BUS_ADDRESS\" > " + out + ".tmp && mv " + out + ".tmp " + out + "\n"
19+ if err := os.WriteFile(filepath.Join(dir, "notify-send"), []byte(script), 0o755); err != nil {
20+ t.Fatal(err)
21+ }
22+ ctx := context.Background()
23+ log := logging.For(ctx, "launcher")
24+ NewNotifier(ctx, []string{"PATH=" + dir + ":/usr/bin:/bin", "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/bus"}, log).Notify("DP-2 scale saved", "-Scale 1.5")
25+ // Notify returns at once; wait for the script to record the call.
26+ data, err := os.ReadFile(out)
27+ for deadline := time.Now().Add(5 * time.Second); err != nil; data, err = os.ReadFile(out) {
28+ if time.Now().After(deadline) {
29+ t.Fatal("notify-send not run")
30+ }
31+ time.Sleep(10 * time.Millisecond)
32+ }
33+ if want := "--app-name=neferwl|--|DP-2 scale saved|-Scale 1.5|unix:path=/tmp/bus|"; string(data) != want {
34+ t.Fatalf("got %q, want %q", data, want)
35+ }
36+ // A missing notify-send is skipped, not fatal.
37+ NewNotifier(ctx, []string{"PATH=" + t.TempDir()}, log).Notify("x", "y")
38+}
M internal/app/app.go +13 -1
@@ -100,7 +100,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
100100 appliedOutput := make(chan ports.OutputApplied, 8)
101101 backendConfig := make(chan ports.Config, 8)
102102 backendApplied := make(chan error, 8)
103- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
103+ var scales chan ports.ScaleChanged
104+ if hw != nil {
105+ // Only real sessions save scales: headless runs never touch the config file.
106+ scales = make(chan ports.ScaleChanged, 8)
107+ }
108+ ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
104109 c, err := core.New(opts.Config, ch)
105110 if err != nil {
106111 return err
@@ -154,6 +159,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
154159 defer workers.Done()
155160 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
156161 }()
162+ if scales != nil {
163+ workers.Add(1)
164+ go func() {
165+ defer workers.Done()
166+ core.PersistScales(ctx, scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), time.Second)
167+ }()
168+ }
157169 filtered := make(chan ports.ConfigChanged, 8)
158170 workers.Add(1)
159171 go func() {
@@ -100,7 +100,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
100 appliedOutput := make(chan ports.OutputApplied, 8)100 appliedOutput := make(chan ports.OutputApplied, 8)
101 backendConfig := make(chan ports.Config, 8)101 backendConfig := make(chan ports.Config, 8)
102 backendApplied := make(chan error, 8)102 backendApplied := make(chan error, 8)
103- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}103+ var scales chan ports.ScaleChanged
104+ if hw != nil {
105+ // Only real sessions save scales: headless runs never touch the config file.
106+ scales = make(chan ports.ScaleChanged, 8)
107+ }
108+ ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
104 c, err := core.New(opts.Config, ch)109 c, err := core.New(opts.Config, ch)
105 if err != nil {110 if err != nil {
106 return err111 return err
@@ -154,6 +159,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
154 defer workers.Done()159 defer workers.Done()
155 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))160 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
156 }()161 }()
162+ if scales != nil {
163+ workers.Add(1)
164+ go func() {
165+ defer workers.Done()
166+ core.PersistScales(ctx, scales, config.ScaleStore{Path: path}, launcher.NewNotifier(ctx, childEnv, logging.For(ctx, "launcher")), time.Second)
167+ }()
168+ }
157 filtered := make(chan ports.ConfigChanged, 8)169 filtered := make(chan ports.ConfigChanged, 8)
158 workers.Add(1)170 workers.Add(1)
159 go func() {171 go func() {
M internal/app/outputmanagement.go +50 -1
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "image"
77 "math"
8+ "reflect"
89 "slices"
910
1011 "github.com/bnema/neferwl/internal/ports"
@@ -42,11 +43,59 @@ func (o *outputOverrides) effective() ports.Config {
4243 }
4344 return cfg
4445 }
46+
47+// reload replaces the file configuration. Runtime overrides are dropped,
48+// unless only output scales changed (a saved scale bind): then they stay and
49+// take the new file scales.
4550 func (o *outputOverrides) reload(file ports.Config) ports.Config {
51+ scales, onlyScales := scaleChanges(o.file, file)
4652 o.file = file
47- clear(o.overrides)
53+ if !onlyScales {
54+ clear(o.overrides)
55+ return o.effective()
56+ }
57+ for name, scale := range scales {
58+ if entry, ok := o.overrides[name]; ok {
59+ entry.Scale = scale
60+ o.overrides[name] = entry
61+ }
62+ }
4863 return o.effective()
4964 }
65+
66+// scaleChanges lists the output scales that differ from old to cur, and
67+// reports whether nothing else differs.
68+func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
69+ scaleOf := func(c ports.Config, name string) float64 {
70+ for _, o := range c.Outputs {
71+ if o.Name == name {
72+ return o.Scale
73+ }
74+ }
75+ return 0
76+ }
77+ // Without scales, an output.<name>.scale-only entry is empty: drop it.
78+ strip := func(c ports.Config) ports.Config {
79+ c.Outputs = slices.Clone(c.Outputs)
80+ for i := range c.Outputs {
81+ c.Outputs[i].Scale = 0
82+ }
83+ c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
84+ return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
85+ })
86+ return c
87+ }
88+ if !reflect.DeepEqual(strip(old), strip(cur)) {
89+ return nil, false
90+ }
91+ scales := map[string]float64{}
92+ for _, o := range cur.Outputs {
93+ if o.Scale != scaleOf(old, o.Name) {
94+ scales[o.Name] = o.Scale
95+ }
96+ }
97+ return scales, true
98+}
5099 func (o *outputOverrides) validate(req ports.OutputApply) error {
51100 if len(req.Heads) != len(o.heads.Heads) {
52101 return fmt.Errorf("incomplete head configuration")
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "image"6 "image"
7 "math"7 "math"
8+ "reflect"
8 "slices"9 "slices"
9 10
10 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
@@ -42,11 +43,59 @@ func (o *outputOverrides) effective() ports.Config {
42 }43 }
43 return cfg44 return cfg
44 }45 }
46+
47+// reload replaces the file configuration. Runtime overrides are dropped,
48+// unless only output scales changed (a saved scale bind): then they stay and
49+// take the new file scales.
45 func (o *outputOverrides) reload(file ports.Config) ports.Config {50 func (o *outputOverrides) reload(file ports.Config) ports.Config {
51+ scales, onlyScales := scaleChanges(o.file, file)
46 o.file = file52 o.file = file
47- clear(o.overrides)53+ if !onlyScales {
54+ clear(o.overrides)
55+ return o.effective()
56+ }
57+ for name, scale := range scales {
58+ if entry, ok := o.overrides[name]; ok {
59+ entry.Scale = scale
60+ o.overrides[name] = entry
61+ }
62+ }
48 return o.effective()63 return o.effective()
49 }64 }
65+
66+// scaleChanges lists the output scales that differ from old to cur, and
67+// reports whether nothing else differs.
68+func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
69+ scaleOf := func(c ports.Config, name string) float64 {
70+ for _, o := range c.Outputs {
71+ if o.Name == name {
72+ return o.Scale
73+ }
74+ }
75+ return 0
76+ }
77+ // Without scales, an output.<name>.scale-only entry is empty: drop it.
78+ strip := func(c ports.Config) ports.Config {
79+ c.Outputs = slices.Clone(c.Outputs)
80+ for i := range c.Outputs {
81+ c.Outputs[i].Scale = 0
82+ }
83+ c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
84+ return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
85+ })
86+ return c
87+ }
88+ if !reflect.DeepEqual(strip(old), strip(cur)) {
89+ return nil, false
90+ }
91+ scales := map[string]float64{}
92+ for _, o := range cur.Outputs {
93+ if o.Scale != scaleOf(old, o.Name) {
94+ scales[o.Name] = o.Scale
95+ }
96+ }
97+ return scales, true
98+}
50 func (o *outputOverrides) validate(req ports.OutputApply) error {99 func (o *outputOverrides) validate(req ports.OutputApply) error {
51 if len(req.Heads) != len(o.heads.Heads) {100 if len(req.Heads) != len(o.heads.Heads) {
52 return fmt.Errorf("incomplete head configuration")101 return fmt.Errorf("incomplete head configuration")
M internal/app/outputmanagement_test.go +23 -0
@@ -210,3 +210,26 @@ func TestReloadDuringPendingApply(t *testing.T) {
210210 <-backend
211211 backendDone <- nil
212212 }
213+
214+// A reload that only changes output scales (a saved scale bind) keeps the
215+// runtime overrides; any other change drops them.
216+func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
217+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
218+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
219+ state := newOutputOverrides(file, false)
220+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}, {Info: ports.OutputInfo{Name: "DP-2"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
221+ if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}, {Name: "DP-2", Enabled: true}}}); err != nil {
222+ t.Fatal(err)
223+ }
224+ scaled := file
225+ scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
226+ cfg := state.reload(scaled)
227+ if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
228+ t.Fatalf("scale-only reload: %+v", cfg.Outputs)
229+ }
230+ other := scaled
231+ other.Layout.Gaps = 4
232+ if cfg := state.reload(other); cfg.Outputs[0].Pos != nil {
233+ t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
234+ }
235+}
@@ -210,3 +210,26 @@ func TestReloadDuringPendingApply(t *testing.T) {
210 <-backend210 <-backend
211 backendDone <- nil211 backendDone <- nil
212 }212 }
213+
214+// A reload that only changes output scales (a saved scale bind) keeps the
215+// runtime overrides; any other change drops them.
216+func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
217+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
218+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
219+ state := newOutputOverrides(file, false)
220+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}, {Info: ports.OutputInfo{Name: "DP-2"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
221+ if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}, {Name: "DP-2", Enabled: true}}}); err != nil {
222+ t.Fatal(err)
223+ }
224+ scaled := file
225+ scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
226+ cfg := state.reload(scaled)
227+ if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
228+ t.Fatalf("scale-only reload: %+v", cfg.Outputs)
229+ }
230+ other := scaled
231+ other.Layout.Gaps = 4
232+ if cfg := state.reload(other); cfg.Outputs[0].Pos != nil {
233+ t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
234+ }
235+}
M internal/core/core.go +11 -0
@@ -38,6 +38,9 @@ type Channels struct {
3838 // latest first (capacity 1, drained like Scenes).
3939 State chan ports.State
4040 Workspaces chan ports.Workspaces
41+ // Scales, when set, receives each scale a scale bind sets, to persist it.
42+ // Core does not wait: a full channel drops the change.
43+ Scales chan<- ports.ScaleChanged
4144 // Terminal enables automatic terminal opening according to terminal.auto-open.
4245 Terminal bool
4346 }
@@ -960,8 +963,16 @@ func (c *Core) Run(ctx context.Context) error {
960963 dir = -1
961964 }
962965 sc := c.cur()
966+ prev := sc.scale
963967 sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
964968 c.order()
969+ if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
970+ // Never wait on persistence: a stalled save drops the change.
971+ select {
972+ case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
973+ default:
974+ }
975+ }
965976 if err := c.publish(ctx); err != nil {
966977 return nil
967978 }
@@ -38,6 +38,9 @@ type Channels struct {
38 // latest first (capacity 1, drained like Scenes).38 // latest first (capacity 1, drained like Scenes).
39 State chan ports.State39 State chan ports.State
40 Workspaces chan ports.Workspaces40 Workspaces chan ports.Workspaces
41+ // Scales, when set, receives each scale a scale bind sets, to persist it.
42+ // Core does not wait: a full channel drops the change.
43+ Scales chan<- ports.ScaleChanged
41 // Terminal enables automatic terminal opening according to terminal.auto-open.44 // Terminal enables automatic terminal opening according to terminal.auto-open.
42 Terminal bool45 Terminal bool
43 }46 }
@@ -960,8 +963,16 @@ func (c *Core) Run(ctx context.Context) error {
960 dir = -1963 dir = -1
961 }964 }
962 sc := c.cur()965 sc := c.cur()
966+ prev := sc.scale
963 sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))967 sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
964 c.order()968 c.order()
969+ if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
970+ // Never wait on persistence: a stalled save drops the change.
971+ select {
972+ case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
973+ default:
974+ }
975+ }
965 if err := c.publish(ctx); err != nil {976 if err := c.publish(ctx); err != nil {
966 return nil977 return nil
967 }978 }
A internal/core/persist.go +42 -0
@@ -0,0 +1,42 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "maps"
7+ "slices"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// PersistScales saves the scales set by scale binds once they settle for
14+// debounce, so a burst of Cmd+= presses writes the config once, and tells
15+// the user through notes. It runs until ctx ends; pending scales are then
16+// dropped.
17+func PersistScales(ctx context.Context, changes <-chan ports.ScaleChanged, store ports.OutputScaleStore, notes ports.Notifier, debounce time.Duration) {
18+ pending := map[string]float64{}
19+ timer := time.NewTimer(debounce)
20+ timer.Stop()
21+ for {
22+ select {
23+ case <-ctx.Done():
24+ timer.Stop()
25+ return
26+ case ev := <-changes:
27+ pending[ev.Output] = ev.Scale
28+ timer.Reset(debounce)
29+ case <-timer.C:
30+ // Sorted: several outputs save and notify in a stable order.
31+ for _, name := range slices.Sorted(maps.Keys(pending)) {
32+ scale := pending[name]
33+ if err := store.SaveOutputScale(name, scale); err != nil {
34+ notes.Notify(name+" scale not saved", err.Error())
35+ continue
36+ }
37+ notes.Notify(name+" scale saved", fmt.Sprintf("Scale %.4g is kept after restart", scale))
38+ }
39+ clear(pending)
40+ }
41+ }
42+}
@@ -0,0 +1,42 @@
1+package core
2+
3+import (
4+ "context"
5+ "fmt"
6+ "maps"
7+ "slices"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+)
12+
13+// PersistScales saves the scales set by scale binds once they settle for
14+// debounce, so a burst of Cmd+= presses writes the config once, and tells
15+// the user through notes. It runs until ctx ends; pending scales are then
16+// dropped.
17+func PersistScales(ctx context.Context, changes <-chan ports.ScaleChanged, store ports.OutputScaleStore, notes ports.Notifier, debounce time.Duration) {
18+ pending := map[string]float64{}
19+ timer := time.NewTimer(debounce)
20+ timer.Stop()
21+ for {
22+ select {
23+ case <-ctx.Done():
24+ timer.Stop()
25+ return
26+ case ev := <-changes:
27+ pending[ev.Output] = ev.Scale
28+ timer.Reset(debounce)
29+ case <-timer.C:
30+ // Sorted: several outputs save and notify in a stable order.
31+ for _, name := range slices.Sorted(maps.Keys(pending)) {
32+ scale := pending[name]
33+ if err := store.SaveOutputScale(name, scale); err != nil {
34+ notes.Notify(name+" scale not saved", err.Error())
35+ continue
36+ }
37+ notes.Notify(name+" scale saved", fmt.Sprintf("Scale %.4g is kept after restart", scale))
38+ }
39+ clear(pending)
40+ }
41+ }
42+}
A internal/core/persist_test.go +83 -0
@@ -0,0 +1,83 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// A burst of scale changes saves only the last scale of each output, once.
16+func TestPersistScalesDebounces(t *testing.T) {
17+ store := portsmocks.NewMockOutputScaleStore(t)
18+ notes := portsmocks.NewMockNotifier(t)
19+ done := make(chan struct{}, 2)
20+ store.EXPECT().SaveOutputScale("DP-1", 1.25).Return(nil).Once()
21+ store.EXPECT().SaveOutputScale("DP-2", 2.0).Return(nil).Once()
22+ notes.EXPECT().Notify("DP-1 scale saved", "Scale 1.25 is kept after restart").Run(func(string, string) { done <- struct{}{} }).Once()
23+ notes.EXPECT().Notify("DP-2 scale saved", "Scale 2 is kept after restart").Run(func(string, string) { done <- struct{}{} }).Once()
24+ changes := make(chan ports.ScaleChanged)
25+ ctx, cancel := context.WithCancel(context.Background())
26+ stopped := make(chan struct{})
27+ go func() { core.PersistScales(ctx, changes, store, notes, 20*time.Millisecond); close(stopped) }()
28+ for _, ev := range []ports.ScaleChanged{{Output: "DP-2", Scale: 1.5}, {Output: "DP-1", Scale: 1.25}, {Output: "DP-2", Scale: 2}} {
29+ changes <- ev
30+ }
31+ receive(t, done)
32+ receive(t, done)
33+ cancel()
34+ <-stopped
35+}
36+
37+// A failed save is reported and not retried.
38+func TestPersistScalesReportsFailure(t *testing.T) {
39+ store := portsmocks.NewMockOutputScaleStore(t)
40+ notes := portsmocks.NewMockNotifier(t)
41+ done := make(chan struct{}, 1)
42+ store.EXPECT().SaveOutputScale("DP-1", 1.5).Return(errors.New("read-only file system")).Once()
43+ notes.EXPECT().Notify("DP-1 scale not saved", "read-only file system").Run(func(string, string) { done <- struct{}{} }).Once()
44+ changes := make(chan ports.ScaleChanged)
45+ ctx, cancel := context.WithCancel(context.Background())
46+ stopped := make(chan struct{})
47+ go func() { core.PersistScales(ctx, changes, store, notes, time.Millisecond); close(stopped) }()
48+ changes <- ports.ScaleChanged{Output: "DP-1", Scale: 1.5}
49+ receive(t, done)
50+ cancel()
51+ <-stopped
52+}
53+
54+// Scale binds report the new scale of the focused output; a bind at the end
55+// of the range changes nothing and reports nothing.
56+func TestScaleBindReportsScale(t *testing.T) {
57+ cfg := config.Defaults()
58+ input := make(chan ports.InputEvent, 8)
59+ output := make(chan ports.OutputEvent, 8)
60+ scales := make(chan ports.ScaleChanged, 8)
61+ scenes := make(chan []ports.Scene, 1)
62+ c, err := core.New(cfg, core.Channels{Input: input, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Scales: scales})
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ ctx, cancel := context.WithCancel(context.Background())
67+ defer cancel()
68+ go c.Run(ctx)
69+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
70+ scene(t, scenes)
71+ press := func(sym string, code uint32) {
72+ input <- ports.KeyEvent{Keysym: sym, Keycode: code, Mods: ports.ModSuper, Pressed: true}
73+ input <- ports.KeyEvent{Keysym: sym, Keycode: code, Mods: ports.ModSuper}
74+ }
75+ press("minus", 12) // already at 1
76+ press("equal", 13)
77+ if got := receive(t, scales); got.Output != "DP-2" || got.Scale <= 1 {
78+ t.Fatalf("%+v", got)
79+ }
80+ if len(scales) != 0 {
81+ t.Fatalf("scale-down at 1 reported %+v", <-scales)
82+ }
83+}
@@ -0,0 +1,83 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12+ "github.com/bnema/neferwl/internal/ports"
13+)
14+
15+// A burst of scale changes saves only the last scale of each output, once.
16+func TestPersistScalesDebounces(t *testing.T) {
17+ store := portsmocks.NewMockOutputScaleStore(t)
18+ notes := portsmocks.NewMockNotifier(t)
19+ done := make(chan struct{}, 2)
20+ store.EXPECT().SaveOutputScale("DP-1", 1.25).Return(nil).Once()
21+ store.EXPECT().SaveOutputScale("DP-2", 2.0).Return(nil).Once()
22+ notes.EXPECT().Notify("DP-1 scale saved", "Scale 1.25 is kept after restart").Run(func(string, string) { done <- struct{}{} }).Once()
23+ notes.EXPECT().Notify("DP-2 scale saved", "Scale 2 is kept after restart").Run(func(string, string) { done <- struct{}{} }).Once()
24+ changes := make(chan ports.ScaleChanged)
25+ ctx, cancel := context.WithCancel(context.Background())
26+ stopped := make(chan struct{})
27+ go func() { core.PersistScales(ctx, changes, store, notes, 20*time.Millisecond); close(stopped) }()
28+ for _, ev := range []ports.ScaleChanged{{Output: "DP-2", Scale: 1.5}, {Output: "DP-1", Scale: 1.25}, {Output: "DP-2", Scale: 2}} {
29+ changes <- ev
30+ }
31+ receive(t, done)
32+ receive(t, done)
33+ cancel()
34+ <-stopped
35+}
36+
37+// A failed save is reported and not retried.
38+func TestPersistScalesReportsFailure(t *testing.T) {
39+ store := portsmocks.NewMockOutputScaleStore(t)
40+ notes := portsmocks.NewMockNotifier(t)
41+ done := make(chan struct{}, 1)
42+ store.EXPECT().SaveOutputScale("DP-1", 1.5).Return(errors.New("read-only file system")).Once()
43+ notes.EXPECT().Notify("DP-1 scale not saved", "read-only file system").Run(func(string, string) { done <- struct{}{} }).Once()
44+ changes := make(chan ports.ScaleChanged)
45+ ctx, cancel := context.WithCancel(context.Background())
46+ stopped := make(chan struct{})
47+ go func() { core.PersistScales(ctx, changes, store, notes, time.Millisecond); close(stopped) }()
48+ changes <- ports.ScaleChanged{Output: "DP-1", Scale: 1.5}
49+ receive(t, done)
50+ cancel()
51+ <-stopped
52+}
53+
54+// Scale binds report the new scale of the focused output; a bind at the end
55+// of the range changes nothing and reports nothing.
56+func TestScaleBindReportsScale(t *testing.T) {
57+ cfg := config.Defaults()
58+ input := make(chan ports.InputEvent, 8)
59+ output := make(chan ports.OutputEvent, 8)
60+ scales := make(chan ports.ScaleChanged, 8)
61+ scenes := make(chan []ports.Scene, 1)
62+ c, err := core.New(cfg, core.Channels{Input: input, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes, Scales: scales})
63+ if err != nil {
64+ t.Fatal(err)
65+ }
66+ ctx, cancel := context.WithCancel(context.Background())
67+ defer cancel()
68+ go c.Run(ctx)
69+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
70+ scene(t, scenes)
71+ press := func(sym string, code uint32) {
72+ input <- ports.KeyEvent{Keysym: sym, Keycode: code, Mods: ports.ModSuper, Pressed: true}
73+ input <- ports.KeyEvent{Keysym: sym, Keycode: code, Mods: ports.ModSuper}
74+ }
75+ press("minus", 12) // already at 1
76+ press("equal", 13)
77+ if got := receive(t, scales); got.Output != "DP-2" || got.Scale <= 1 {
78+ t.Fatalf("%+v", got)
79+ }
80+ if len(scales) != 0 {
81+ t.Fatalf("scale-down at 1 reported %+v", <-scales)
82+ }
83+}
A internal/mocks/ports/desktop_mock.go +184 -0
@@ -0,0 +1,184 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockNotifier creates a new instance of MockNotifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockNotifier(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockNotifier {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockNotifier{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockNotifier is an autogenerated mock type for the Notifier type
35+type MockNotifier struct {
36+ mock.Mock
37+}
38+
39+type MockNotifier_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockNotifier) EXPECT() *MockNotifier_Expecter {
44+ return &MockNotifier_Expecter{mock: &_m.Mock}
45+}
46+
47+// Notify provides a mock function for the type MockNotifier
48+func (_mock *MockNotifier) Notify(summary string, body string) {
49+ _mock.Called(summary, body)
50+ return
51+}
52+
53+// MockNotifier_Notify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Notify'
54+type MockNotifier_Notify_Call struct {
55+ *mock.Call
56+}
57+
58+// Notify is a helper method to define mock.On call
59+// - summary string
60+// - body string
61+func (_e *MockNotifier_Expecter) Notify(summary any, body any) *MockNotifier_Notify_Call {
62+ return &MockNotifier_Notify_Call{Call: _e.mock.On("Notify", summary, body)}
63+}
64+
65+func (_c *MockNotifier_Notify_Call) Run(run func(summary string, body string)) *MockNotifier_Notify_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 string
68+ if args[0] != nil {
69+ arg0 = args[0].(string)
70+ }
71+ var arg1 string
72+ if args[1] != nil {
73+ arg1 = args[1].(string)
74+ }
75+ run(
76+ arg0,
77+ arg1,
78+ )
79+ })
80+ return _c
81+}
82+
83+func (_c *MockNotifier_Notify_Call) Return() *MockNotifier_Notify_Call {
84+ _c.Call.Return()
85+ return _c
86+}
87+
88+func (_c *MockNotifier_Notify_Call) RunAndReturn(run func(summary string, body string)) *MockNotifier_Notify_Call {
89+ _c.Run(run)
90+ return _c
91+}
92+
93+// NewMockOutputScaleStore creates a new instance of MockOutputScaleStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
94+// The first argument is typically a *testing.T value.
95+func NewMockOutputScaleStore(t interface {
96+ mock.TestingT
97+ Cleanup(func())
98+}) *MockOutputScaleStore {
99+ if helper, ok := t.(interface{ Helper() }); ok {
100+ helper.Helper()
101+ }
102+
103+ mock := &MockOutputScaleStore{}
104+ mock.Mock.Test(t)
105+
106+ t.Cleanup(func() {
107+ if helper, ok := t.(interface{ Helper() }); ok {
108+ helper.Helper()
109+ }
110+ mock.AssertExpectations(t)
111+ })
112+
113+ return mock
114+}
115+
116+// MockOutputScaleStore is an autogenerated mock type for the OutputScaleStore type
117+type MockOutputScaleStore struct {
118+ mock.Mock
119+}
120+
121+type MockOutputScaleStore_Expecter struct {
122+ mock *mock.Mock
123+}
124+
125+func (_m *MockOutputScaleStore) EXPECT() *MockOutputScaleStore_Expecter {
126+ return &MockOutputScaleStore_Expecter{mock: &_m.Mock}
127+}
128+
129+// SaveOutputScale provides a mock function for the type MockOutputScaleStore
130+func (_mock *MockOutputScaleStore) SaveOutputScale(output string, scale float64) error {
131+ ret := _mock.Called(output, scale)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for SaveOutputScale")
135+ }
136+
137+ var r0 error
138+ if returnFunc, ok := ret.Get(0).(func(string, float64) error); ok {
139+ r0 = returnFunc(output, scale)
140+ } else {
141+ r0 = ret.Error(0)
142+ }
143+ return r0
144+}
145+
146+// MockOutputScaleStore_SaveOutputScale_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOutputScale'
147+type MockOutputScaleStore_SaveOutputScale_Call struct {
148+ *mock.Call
149+}
150+
151+// SaveOutputScale is a helper method to define mock.On call
152+// - output string
153+// - scale float64
154+func (_e *MockOutputScaleStore_Expecter) SaveOutputScale(output any, scale any) *MockOutputScaleStore_SaveOutputScale_Call {
155+ return &MockOutputScaleStore_SaveOutputScale_Call{Call: _e.mock.On("SaveOutputScale", output, scale)}
156+}
157+
158+func (_c *MockOutputScaleStore_SaveOutputScale_Call) Run(run func(output string, scale float64)) *MockOutputScaleStore_SaveOutputScale_Call {
159+ _c.Call.Run(func(args mock.Arguments) {
160+ var arg0 string
161+ if args[0] != nil {
162+ arg0 = args[0].(string)
163+ }
164+ var arg1 float64
165+ if args[1] != nil {
166+ arg1 = args[1].(float64)
167+ }
168+ run(
169+ arg0,
170+ arg1,
171+ )
172+ })
173+ return _c
174+}
175+
176+func (_c *MockOutputScaleStore_SaveOutputScale_Call) Return(err error) *MockOutputScaleStore_SaveOutputScale_Call {
177+ _c.Call.Return(err)
178+ return _c
179+}
180+
181+func (_c *MockOutputScaleStore_SaveOutputScale_Call) RunAndReturn(run func(output string, scale float64) error) *MockOutputScaleStore_SaveOutputScale_Call {
182+ _c.Call.Return(run)
183+ return _c
184+}
@@ -0,0 +1,184 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockNotifier creates a new instance of MockNotifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockNotifier(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockNotifier {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockNotifier{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockNotifier is an autogenerated mock type for the Notifier type
35+type MockNotifier struct {
36+ mock.Mock
37+}
38+
39+type MockNotifier_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockNotifier) EXPECT() *MockNotifier_Expecter {
44+ return &MockNotifier_Expecter{mock: &_m.Mock}
45+}
46+
47+// Notify provides a mock function for the type MockNotifier
48+func (_mock *MockNotifier) Notify(summary string, body string) {
49+ _mock.Called(summary, body)
50+ return
51+}
52+
53+// MockNotifier_Notify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Notify'
54+type MockNotifier_Notify_Call struct {
55+ *mock.Call
56+}
57+
58+// Notify is a helper method to define mock.On call
59+// - summary string
60+// - body string
61+func (_e *MockNotifier_Expecter) Notify(summary any, body any) *MockNotifier_Notify_Call {
62+ return &MockNotifier_Notify_Call{Call: _e.mock.On("Notify", summary, body)}
63+}
64+
65+func (_c *MockNotifier_Notify_Call) Run(run func(summary string, body string)) *MockNotifier_Notify_Call {
66+ _c.Call.Run(func(args mock.Arguments) {
67+ var arg0 string
68+ if args[0] != nil {
69+ arg0 = args[0].(string)
70+ }
71+ var arg1 string
72+ if args[1] != nil {
73+ arg1 = args[1].(string)
74+ }
75+ run(
76+ arg0,
77+ arg1,
78+ )
79+ })
80+ return _c
81+}
82+
83+func (_c *MockNotifier_Notify_Call) Return() *MockNotifier_Notify_Call {
84+ _c.Call.Return()
85+ return _c
86+}
87+
88+func (_c *MockNotifier_Notify_Call) RunAndReturn(run func(summary string, body string)) *MockNotifier_Notify_Call {
89+ _c.Run(run)
90+ return _c
91+}
92+
93+// NewMockOutputScaleStore creates a new instance of MockOutputScaleStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
94+// The first argument is typically a *testing.T value.
95+func NewMockOutputScaleStore(t interface {
96+ mock.TestingT
97+ Cleanup(func())
98+}) *MockOutputScaleStore {
99+ if helper, ok := t.(interface{ Helper() }); ok {
100+ helper.Helper()
101+ }
102+
103+ mock := &MockOutputScaleStore{}
104+ mock.Mock.Test(t)
105+
106+ t.Cleanup(func() {
107+ if helper, ok := t.(interface{ Helper() }); ok {
108+ helper.Helper()
109+ }
110+ mock.AssertExpectations(t)
111+ })
112+
113+ return mock
114+}
115+
116+// MockOutputScaleStore is an autogenerated mock type for the OutputScaleStore type
117+type MockOutputScaleStore struct {
118+ mock.Mock
119+}
120+
121+type MockOutputScaleStore_Expecter struct {
122+ mock *mock.Mock
123+}
124+
125+func (_m *MockOutputScaleStore) EXPECT() *MockOutputScaleStore_Expecter {
126+ return &MockOutputScaleStore_Expecter{mock: &_m.Mock}
127+}
128+
129+// SaveOutputScale provides a mock function for the type MockOutputScaleStore
130+func (_mock *MockOutputScaleStore) SaveOutputScale(output string, scale float64) error {
131+ ret := _mock.Called(output, scale)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for SaveOutputScale")
135+ }
136+
137+ var r0 error
138+ if returnFunc, ok := ret.Get(0).(func(string, float64) error); ok {
139+ r0 = returnFunc(output, scale)
140+ } else {
141+ r0 = ret.Error(0)
142+ }
143+ return r0
144+}
145+
146+// MockOutputScaleStore_SaveOutputScale_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveOutputScale'
147+type MockOutputScaleStore_SaveOutputScale_Call struct {
148+ *mock.Call
149+}
150+
151+// SaveOutputScale is a helper method to define mock.On call
152+// - output string
153+// - scale float64
154+func (_e *MockOutputScaleStore_Expecter) SaveOutputScale(output any, scale any) *MockOutputScaleStore_SaveOutputScale_Call {
155+ return &MockOutputScaleStore_SaveOutputScale_Call{Call: _e.mock.On("SaveOutputScale", output, scale)}
156+}
157+
158+func (_c *MockOutputScaleStore_SaveOutputScale_Call) Run(run func(output string, scale float64)) *MockOutputScaleStore_SaveOutputScale_Call {
159+ _c.Call.Run(func(args mock.Arguments) {
160+ var arg0 string
161+ if args[0] != nil {
162+ arg0 = args[0].(string)
163+ }
164+ var arg1 float64
165+ if args[1] != nil {
166+ arg1 = args[1].(float64)
167+ }
168+ run(
169+ arg0,
170+ arg1,
171+ )
172+ })
173+ return _c
174+}
175+
176+func (_c *MockOutputScaleStore_SaveOutputScale_Call) Return(err error) *MockOutputScaleStore_SaveOutputScale_Call {
177+ _c.Call.Return(err)
178+ return _c
179+}
180+
181+func (_c *MockOutputScaleStore_SaveOutputScale_Call) RunAndReturn(run func(output string, scale float64) error) *MockOutputScaleStore_SaveOutputScale_Call {
182+ _c.Call.Return(run)
183+ return _c
184+}
A internal/ports/desktop.go +13 -0
@@ -0,0 +1,13 @@
1+package ports
2+
3+// Notifier shows desktop notifications (e.g. through mako). Notify does not
4+// block; delivery is best effort and implementations log their own failures.
5+type Notifier interface {
6+ Notify(summary, body string)
7+}
8+
9+// OutputScaleStore persists output scales so they survive a restart.
10+type OutputScaleStore interface {
11+ // SaveOutputScale records scale as the configured scale of the output.
12+ SaveOutputScale(output string, scale float64) error
13+}
@@ -0,0 +1,13 @@
1+package ports
2+
3+// Notifier shows desktop notifications (e.g. through mako). Notify does not
4+// block; delivery is best effort and implementations log their own failures.
5+type Notifier interface {
6+ Notify(summary, body string)
7+}
8+
9+// OutputScaleStore persists output scales so they survive a restart.
10+type OutputScaleStore interface {
11+ // SaveOutputScale records scale as the configured scale of the output.
12+ SaveOutputScale(output string, scale float64) error
13+}
M internal/ports/messages.go +6 -0
@@ -498,6 +498,12 @@ func (OutputRemoved) outputEvent() {}
498498 // ConfigChanged carries config → core reloads.
499499 type ConfigChanged struct{ Config Config }
500500
501+// ScaleChanged carries core → persistence a scale set by a scale bind.
502+type ScaleChanged struct {
503+ Output string
504+ Scale float64
505+}
506+
501507 // ClientCommand carries core → wayland commands.
502508 type ClientCommand interface{ clientCommand() }
503509
@@ -498,6 +498,12 @@ func (OutputRemoved) outputEvent() {}
498 // ConfigChanged carries config → core reloads.498 // ConfigChanged carries config → core reloads.
499 type ConfigChanged struct{ Config Config }499 type ConfigChanged struct{ Config Config }
500 500
501+// ScaleChanged carries core → persistence a scale set by a scale bind.
502+type ScaleChanged struct {
503+ Output string
504+ Scale float64
505+}
506+
501 // ClientCommand carries core → wayland commands.507 // ClientCommand carries core → wayland commands.
502 type ClientCommand interface{ clientCommand() }508 type ClientCommand interface{ clientCommand() }
503 509

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