detached b931c88

feat(scale): save scale-bind changes to the config with a notification

Scale binds now send ScaleChanged to core.PersistScales, which waits one
second without changes, writes output.<name>.scale in place through the
new OutputScaleStore port, and reports it through the new Notifier
port (notify-send). A scale-only reload keeps runtime output overrides.
commit
b931c88b02d1c8e9953da0bea01172476cf9e172
author
brice · · 2026-09-27 13:36 UTC
parent
5731344
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +732 -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
@@ -85,7 +85,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
8585 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
8686 | `focus-monitor-left/right` | Focus the neighbor monitor |
8787 | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |
88-| `scale-up` / `scale-down` | Zoom the whole display |
88+| `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`) |
8989 | `quit` | Exit NeferWL |
9090
9191 ### Default binds
@@ -85,7 +85,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
85 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |85 | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window |
86 | `focus-monitor-left/right` | Focus the neighbor monitor |86 | `focus-monitor-left/right` | Focus the neighbor monitor |
87 | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |87 | `move-workspace-to-monitor-left/right` | Move the workspace; it gets a new home |
88-| `scale-up` / `scale-down` | Zoom the whole display |88+| `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`) |
89 | `quit` | Exit NeferWL |89 | `quit` | Exit NeferWL |
90 90
91 ### Default binds91 ### Default binds
M examples/config +2 -1
@@ -64,7 +64,8 @@ focus.follow-move = off
6464 # output.DP-2 = 5120x2160@165.058
6565 # output.HDMI-A-1 = off
6666 # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down
67-# step through the scales where the display divides into whole pixels.
67+# step through the scales where the display divides into whole pixels and
68+# save the result here.
6869 # output.DP-2.scale = 4/3
6970
7071 render.direct-scanout = on
@@ -64,7 +64,8 @@ focus.follow-move = off
64 # output.DP-2 = 5120x2160@165.05864 # output.DP-2 = 5120x2160@165.058
65 # output.HDMI-A-1 = off65 # output.HDMI-A-1 = off
66 # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down66 # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down
67-# step through the scales where the display divides into whole pixels.67+# step through the scales where the display divides into whole pixels and
68+# save the result here.
68 # output.DP-2.scale = 4/369 # output.DP-2.scale = 4/3
69 70
70 render.direct-scanout = on71 render.direct-scanout = on
A internal/adapters/config/scalestore.go +122 -0
@@ -0,0 +1,122 @@
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+ mode := os.FileMode(0o644)
31+ switch {
32+ case errors.Is(err, os.ErrNotExist):
33+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
34+ return err
35+ }
36+ case err != nil:
37+ return err
38+ default:
39+ if info, err := os.Stat(path); err == nil {
40+ mode = info.Mode().Perm()
41+ }
42+ }
43+ return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
44+}
45+
46+// setKey replaces the value of the last line setting key (the one parse
47+// keeps) or appends key = value.
48+func setKey(data []byte, key, value string) []byte {
49+ lines := strings.SplitAfter(string(data), "\n")
50+ for i := len(lines) - 1; i >= 0; i-- {
51+ k, v, ok := strings.Cut(lines[i], "=")
52+ if !ok || strings.TrimSpace(k) != key {
53+ continue
54+ }
55+ // Keep the spacing around = and an end-of-line comment.
56+ rest := strings.TrimRight(v, "\r\n")
57+ eol := v[len(rest):]
58+ old := stripComment(strings.TrimSpace(rest))
59+ start := strings.Index(rest, old)
60+ lines[i] = k + "=" + rest[:start] + value + rest[start+len(old):] + eol
61+ return []byte(strings.Join(lines, ""))
62+ }
63+ var b bytes.Buffer
64+ b.Write(data)
65+ if len(data) > 0 && data[len(data)-1] != '\n' {
66+ b.WriteByte('\n')
67+ }
68+ fmt.Fprintf(&b, "%s = %s\n", key, value)
69+ return b.Bytes()
70+}
71+
72+// formatScale writes scales as the config expects them: a decimal when it is
73+// exact (1.25), else a fraction (4/3).
74+func formatScale(s float64) string {
75+ n, d := int(math.Round(s*120)), 120
76+ g := gcd(n, d)
77+ n, d = n/g, d/g
78+ den := d
79+ for den%2 == 0 {
80+ den /= 2
81+ }
82+ for den%5 == 0 {
83+ den /= 5
84+ }
85+ if den == 1 {
86+ return strconv.FormatFloat(float64(n)/float64(d), 'f', -1, 64)
87+ }
88+ return fmt.Sprintf("%d/%d", n, d)
89+}
90+
91+func gcd(a, b int) int {
92+ for b != 0 {
93+ a, b = b, a%b
94+ }
95+ return a
96+}
97+
98+// writeAtomic replaces path with data: readers and the watcher never see a
99+// half-written file.
100+func writeAtomic(path string, data []byte, mode os.FileMode) error {
101+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
102+ if err != nil {
103+ return err
104+ }
105+ defer os.Remove(tmp.Name())
106+ if _, err := tmp.Write(data); err != nil {
107+ tmp.Close()
108+ return err
109+ }
110+ if err := tmp.Chmod(mode); err != nil {
111+ tmp.Close()
112+ return err
113+ }
114+ if err := tmp.Sync(); err != nil {
115+ tmp.Close()
116+ return err
117+ }
118+ if err := tmp.Close(); err != nil {
119+ return err
120+ }
121+ return os.Rename(tmp.Name(), path)
122+}
@@ -0,0 +1,122 @@
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+ mode := os.FileMode(0o644)
31+ switch {
32+ case errors.Is(err, os.ErrNotExist):
33+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
34+ return err
35+ }
36+ case err != nil:
37+ return err
38+ default:
39+ if info, err := os.Stat(path); err == nil {
40+ mode = info.Mode().Perm()
41+ }
42+ }
43+ return writeAtomic(path, setKey(data, "output."+output+".scale", formatScale(scale)), mode)
44+}
45+
46+// setKey replaces the value of the last line setting key (the one parse
47+// keeps) or appends key = value.
48+func setKey(data []byte, key, value string) []byte {
49+ lines := strings.SplitAfter(string(data), "\n")
50+ for i := len(lines) - 1; i >= 0; i-- {
51+ k, v, ok := strings.Cut(lines[i], "=")
52+ if !ok || strings.TrimSpace(k) != key {
53+ continue
54+ }
55+ // Keep the spacing around = and an end-of-line comment.
56+ rest := strings.TrimRight(v, "\r\n")
57+ eol := v[len(rest):]
58+ old := stripComment(strings.TrimSpace(rest))
59+ start := strings.Index(rest, old)
60+ lines[i] = k + "=" + rest[:start] + value + rest[start+len(old):] + eol
61+ return []byte(strings.Join(lines, ""))
62+ }
63+ var b bytes.Buffer
64+ b.Write(data)
65+ if len(data) > 0 && data[len(data)-1] != '\n' {
66+ b.WriteByte('\n')
67+ }
68+ fmt.Fprintf(&b, "%s = %s\n", key, value)
69+ return b.Bytes()
70+}
71+
72+// formatScale writes scales as the config expects them: a decimal when it is
73+// exact (1.25), else a fraction (4/3).
74+func formatScale(s float64) string {
75+ n, d := int(math.Round(s*120)), 120
76+ g := gcd(n, d)
77+ n, d = n/g, d/g
78+ den := d
79+ for den%2 == 0 {
80+ den /= 2
81+ }
82+ for den%5 == 0 {
83+ den /= 5
84+ }
85+ if den == 1 {
86+ return strconv.FormatFloat(float64(n)/float64(d), 'f', -1, 64)
87+ }
88+ return fmt.Sprintf("%d/%d", n, d)
89+}
90+
91+func gcd(a, b int) int {
92+ for b != 0 {
93+ a, b = b, a%b
94+ }
95+ return a
96+}
97+
98+// writeAtomic replaces path with data: readers and the watcher never see a
99+// half-written file.
100+func writeAtomic(path string, data []byte, mode os.FileMode) error {
101+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
102+ if err != nil {
103+ return err
104+ }
105+ defer os.Remove(tmp.Name())
106+ if _, err := tmp.Write(data); err != nil {
107+ tmp.Close()
108+ return err
109+ }
110+ if err := tmp.Chmod(mode); err != nil {
111+ tmp.Close()
112+ return err
113+ }
114+ if err := tmp.Sync(); err != nil {
115+ tmp.Close()
116+ return err
117+ }
118+ if err := tmp.Close(); err != nil {
119+ return err
120+ }
121+ return os.Rename(tmp.Name(), path)
122+}
A internal/adapters/config/scalestore_test.go +102 -0
@@ -0,0 +1,102 @@
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 spacing", "output.DP-2.scale=2\n", "output.DP-2.scale=4/3\n", 4.0 / 3},
19+ {"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},
20+ {"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},
21+ } {
22+ t.Run(tc.name, func(t *testing.T) {
23+ path := filepath.Join(t.TempDir(), "neferwl", "config")
24+ if tc.in != "" {
25+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := os.WriteFile(path, []byte(tc.in), 0o600); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ if err := (ScaleStore{Path: path}).SaveOutputScale("DP-2", tc.scale); err != nil {
33+ t.Fatal(err)
34+ }
35+ got, err := os.ReadFile(path)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if string(got) != tc.want {
40+ t.Fatalf("got\n%s\nwant\n%s", got, tc.want)
41+ }
42+ // The written value parses back to the same scale.
43+ cfg, _, err := Load(path)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ for _, o := range cfg.Outputs {
48+ if o.Name == "DP-2" && o.Scale != tc.scale {
49+ t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)
50+ }
51+ }
52+ if tc.in != "" {
53+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
54+ t.Fatalf("mode %v", info.Mode().Perm())
55+ }
56+ }
57+ })
58+ }
59+}
60+
61+// A symlinked config (dotfiles) stays a symlink; its target gets the value.
62+func TestSaveOutputScaleSymlink(t *testing.T) {
63+ dir := t.TempDir()
64+ target := filepath.Join(dir, "dotfiles-config")
65+ if err := os.WriteFile(target, []byte("border.width = 3\n"), 0o644); err != nil {
66+ t.Fatal(err)
67+ }
68+ link := filepath.Join(dir, "config")
69+ if err := os.Symlink(target, link); err != nil {
70+ t.Fatal(err)
71+ }
72+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err != nil {
73+ t.Fatal(err)
74+ }
75+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
76+ t.Fatal("symlink replaced", err)
77+ }
78+ if got, _ := os.ReadFile(target); string(got) != "border.width = 3\noutput.DP-2.scale = 2\n" {
79+ t.Fatalf("%q", got)
80+ }
81+ // A dangling link is an error and stays in place.
82+ if err := os.Remove(target); err != nil {
83+ t.Fatal(err)
84+ }
85+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err == nil {
86+ t.Fatal("dangling link saved")
87+ }
88+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
89+ t.Fatal("dangling symlink replaced", err)
90+ }
91+}
92+
93+// Every clean scale is written in a form the parser reads back exactly.
94+func TestFormatScaleRoundTrip(t *testing.T) {
95+ for n := 120; n <= 480; n++ {
96+ s := float64(n) / 120
97+ got, err := parseScale(formatScale(s))
98+ if err != nil || math.Round(got*120) != float64(n) {
99+ t.Fatalf("%d/120: %q parsed as %v, %v", n, formatScale(s), got, err)
100+ }
101+ }
102+}
@@ -0,0 +1,102 @@
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 spacing", "output.DP-2.scale=2\n", "output.DP-2.scale=4/3\n", 4.0 / 3},
19+ {"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},
20+ {"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},
21+ } {
22+ t.Run(tc.name, func(t *testing.T) {
23+ path := filepath.Join(t.TempDir(), "neferwl", "config")
24+ if tc.in != "" {
25+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
26+ t.Fatal(err)
27+ }
28+ if err := os.WriteFile(path, []byte(tc.in), 0o600); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ if err := (ScaleStore{Path: path}).SaveOutputScale("DP-2", tc.scale); err != nil {
33+ t.Fatal(err)
34+ }
35+ got, err := os.ReadFile(path)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if string(got) != tc.want {
40+ t.Fatalf("got\n%s\nwant\n%s", got, tc.want)
41+ }
42+ // The written value parses back to the same scale.
43+ cfg, _, err := Load(path)
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ for _, o := range cfg.Outputs {
48+ if o.Name == "DP-2" && o.Scale != tc.scale {
49+ t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)
50+ }
51+ }
52+ if tc.in != "" {
53+ if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
54+ t.Fatalf("mode %v", info.Mode().Perm())
55+ }
56+ }
57+ })
58+ }
59+}
60+
61+// A symlinked config (dotfiles) stays a symlink; its target gets the value.
62+func TestSaveOutputScaleSymlink(t *testing.T) {
63+ dir := t.TempDir()
64+ target := filepath.Join(dir, "dotfiles-config")
65+ if err := os.WriteFile(target, []byte("border.width = 3\n"), 0o644); err != nil {
66+ t.Fatal(err)
67+ }
68+ link := filepath.Join(dir, "config")
69+ if err := os.Symlink(target, link); err != nil {
70+ t.Fatal(err)
71+ }
72+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err != nil {
73+ t.Fatal(err)
74+ }
75+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
76+ t.Fatal("symlink replaced", err)
77+ }
78+ if got, _ := os.ReadFile(target); string(got) != "border.width = 3\noutput.DP-2.scale = 2\n" {
79+ t.Fatalf("%q", got)
80+ }
81+ // A dangling link is an error and stays in place.
82+ if err := os.Remove(target); err != nil {
83+ t.Fatal(err)
84+ }
85+ if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err == nil {
86+ t.Fatal("dangling link saved")
87+ }
88+ if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
89+ t.Fatal("dangling symlink replaced", err)
90+ }
91+}
92+
93+// Every clean scale is written in a form the parser reads back exactly.
94+func TestFormatScaleRoundTrip(t *testing.T) {
95+ for n := 120; n <= 480; n++ {
96+ s := float64(n) / 120
97+ got, err := parseScale(formatScale(s))
98+ if err != nil || math.Round(got*120) != float64(n) {
99+ t.Fatalf("%d/120: %q parsed as %v, %v", n, formatScale(s), got, err)
100+ }
101+ }
102+}
A internal/adapters/launcher/notify.go +41 -0
@@ -0,0 +1,41 @@
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+ env []string
15+ log zerowrap.Logger
16+}
17+
18+// NewNotifier runs notify-send with the child environment, which carries
19+// the session bus address.
20+func NewNotifier(env []string, log zerowrap.Logger) *Notifier {
21+ return &Notifier{env: append([]string(nil), env...), log: log}
22+}
23+
24+// Notify starts notify-send and returns; it is killed after five seconds.
25+// A missing tool or daemon only logs a warning.
26+func (n *Notifier) Notify(summary, body string) {
27+ path, err := childLookPath("notify-send", n.env)
28+ if err != nil {
29+ n.log.Warn().Err(err).Str("summary", summary).Msg("notification skipped: notify-send not found")
30+ return
31+ }
32+ go func() {
33+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
34+ defer cancel()
35+ cmd := exec.CommandContext(ctx, path, "--app-name=neferwl", "--", summary, body)
36+ cmd.Env = n.env
37+ if out, err := cmd.CombinedOutput(); err != nil {
38+ n.log.Warn().Err(err).Str("output", string(out)).Str("summary", summary).Msg("notification failed")
39+ }
40+ }()
41+}
@@ -0,0 +1,41 @@
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+ env []string
15+ log zerowrap.Logger
16+}
17+
18+// NewNotifier runs notify-send with the child environment, which carries
19+// the session bus address.
20+func NewNotifier(env []string, log zerowrap.Logger) *Notifier {
21+ return &Notifier{env: append([]string(nil), env...), log: log}
22+}
23+
24+// Notify starts notify-send and returns; it is killed after five seconds.
25+// A missing tool or daemon only logs a warning.
26+func (n *Notifier) Notify(summary, body string) {
27+ path, err := childLookPath("notify-send", n.env)
28+ if err != nil {
29+ n.log.Warn().Err(err).Str("summary", summary).Msg("notification skipped: notify-send not found")
30+ return
31+ }
32+ go func() {
33+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
34+ defer cancel()
35+ cmd := exec.CommandContext(ctx, path, "--app-name=neferwl", "--", summary, body)
36+ cmd.Env = n.env
37+ if out, err := cmd.CombinedOutput(); err != nil {
38+ n.log.Warn().Err(err).Str("output", string(out)).Str("summary", summary).Msg("notification failed")
39+ }
40+ }()
41+}
A internal/adapters/launcher/notify_test.go +37 -0
@@ -0,0 +1,37 @@
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+ log := logging.For(context.Background(), "launcher")
23+ NewNotifier([]string{"PATH=" + dir + ":/usr/bin:/bin", "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/bus"}, log).Notify("DP-2 scale saved", "-Scale 1.5")
24+ // Notify returns at once; wait for the script to record the call.
25+ data, err := os.ReadFile(out)
26+ for deadline := time.Now().Add(5 * time.Second); err != nil; data, err = os.ReadFile(out) {
27+ if time.Now().After(deadline) {
28+ t.Fatal("notify-send not run")
29+ }
30+ time.Sleep(10 * time.Millisecond)
31+ }
32+ if want := "--app-name=neferwl|--|DP-2 scale saved|-Scale 1.5|unix:path=/tmp/bus|"; string(data) != want {
33+ t.Fatalf("got %q, want %q", data, want)
34+ }
35+ // A missing notify-send is skipped, not fatal.
36+ NewNotifier([]string{"PATH=" + t.TempDir()}, log).Notify("x", "y")
37+}
@@ -0,0 +1,37 @@
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+ log := logging.For(context.Background(), "launcher")
23+ NewNotifier([]string{"PATH=" + dir + ":/usr/bin:/bin", "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/bus"}, log).Notify("DP-2 scale saved", "-Scale 1.5")
24+ // Notify returns at once; wait for the script to record the call.
25+ data, err := os.ReadFile(out)
26+ for deadline := time.Now().Add(5 * time.Second); err != nil; data, err = os.ReadFile(out) {
27+ if time.Now().After(deadline) {
28+ t.Fatal("notify-send not run")
29+ }
30+ time.Sleep(10 * time.Millisecond)
31+ }
32+ if want := "--app-name=neferwl|--|DP-2 scale saved|-Scale 1.5|unix:path=/tmp/bus|"; string(data) != want {
33+ t.Fatalf("got %q, want %q", data, want)
34+ }
35+ // A missing notify-send is skipped, not fatal.
36+ NewNotifier([]string{"PATH=" + t.TempDir()}, log).Notify("x", "y")
37+}
M internal/app/app.go +13 -1
@@ -99,7 +99,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9999 appliedOutput := make(chan ports.OutputApplied, 8)
100100 backendConfig := make(chan ports.Config, 8)
101101 backendApplied := make(chan error, 8)
102- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
102+ var scales chan ports.ScaleChanged
103+ if hw != nil {
104+ // Only real sessions save scales: headless runs never touch the config file.
105+ scales = make(chan ports.ScaleChanged, 8)
106+ }
107+ 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, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
103108 c, err := core.New(opts.Config, ch)
104109 if err != nil {
105110 return err
@@ -152,6 +157,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
152157 defer workers.Done()
153158 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
154159 }()
160+ if scales != nil {
161+ workers.Add(1)
162+ go func() {
163+ defer workers.Done()
164+ core.PersistScales(ctx, scales, config.ScaleStore{Path: path}, launcher.NewNotifier(childEnv, logging.For(ctx, "launcher")), time.Second)
165+ }()
166+ }
155167 filtered := make(chan ports.ConfigChanged, 8)
156168 workers.Add(1)
157169 go func() {
@@ -99,7 +99,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99 appliedOutput := make(chan ports.OutputApplied, 8)99 appliedOutput := make(chan ports.OutputApplied, 8)
100 backendConfig := make(chan ports.Config, 8)100 backendConfig := make(chan ports.Config, 8)
101 backendApplied := make(chan error, 8)101 backendApplied := make(chan error, 8)
102- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}102+ var scales chan ports.ScaleChanged
103+ if hw != nil {
104+ // Only real sessions save scales: headless runs never touch the config file.
105+ scales = make(chan ports.ScaleChanged, 8)
106+ }
107+ 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, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
103 c, err := core.New(opts.Config, ch)108 c, err := core.New(opts.Config, ch)
104 if err != nil {109 if err != nil {
105 return err110 return err
@@ -152,6 +157,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
152 defer workers.Done()157 defer workers.Done()
153 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))158 done <- config.Watch(ctx, path, watched, logging.For(ctx, "config"))
154 }()159 }()
160+ if scales != nil {
161+ workers.Add(1)
162+ go func() {
163+ defer workers.Done()
164+ core.PersistScales(ctx, scales, config.ScaleStore{Path: path}, launcher.NewNotifier(childEnv, logging.For(ctx, "launcher")), time.Second)
165+ }()
166+ }
155 filtered := make(chan ports.ConfigChanged, 8)167 filtered := make(chan ports.ConfigChanged, 8)
156 workers.Add(1)168 workers.Add(1)
157 go func() {169 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
@@ -37,6 +37,9 @@ type Channels struct {
3737 // State, when set, receives a snapshot for scripts whenever it changes,
3838 // latest first (capacity 1, drained like Scenes).
3939 State chan ports.State
40+ // Scales, when set, receives each scale a scale bind sets, to persist it.
41+ // Core does not wait: a full channel drops the change.
42+ Scales chan<- ports.ScaleChanged
4043 // Terminal, when set, keeps a window on every workspace on screen: an
4144 // empty one gets the configured terminal (the terminal is the desktop).
4245 Terminal bool
@@ -910,8 +913,16 @@ func (c *Core) Run(ctx context.Context) error {
910913 dir = -1
911914 }
912915 sc := c.cur()
916+ prev := sc.scale
913917 sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
914918 c.order()
919+ if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
920+ // Never wait on persistence: a stalled save drops the change.
921+ select {
922+ case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
923+ default:
924+ }
925+ }
915926 if err := c.publish(ctx); err != nil {
916927 return nil
917928 }
@@ -37,6 +37,9 @@ type Channels struct {
37 // State, when set, receives a snapshot for scripts whenever it changes,37 // State, when set, receives a snapshot for scripts whenever it changes,
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+ // Scales, when set, receives each scale a scale bind sets, to persist it.
41+ // Core does not wait: a full channel drops the change.
42+ Scales chan<- ports.ScaleChanged
40 // Terminal, when set, keeps a window on every workspace on screen: an43 // Terminal, when set, keeps a window on every workspace on screen: an
41 // empty one gets the configured terminal (the terminal is the desktop).44 // empty one gets the configured terminal (the terminal is the desktop).
42 Terminal bool45 Terminal bool
@@ -910,8 +913,16 @@ func (c *Core) Run(ctx context.Context) error {
910 dir = -1913 dir = -1
911 }914 }
912 sc := c.cur()915 sc := c.cur()
916+ prev := sc.scale
913 sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))917 sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
914 c.order()918 c.order()
919+ if c.ch.Scales != nil && sc.scale != prev && sc.name() != "" {
920+ // Never wait on persistence: a stalled save drops the change.
921+ select {
922+ case c.ch.Scales <- ports.ScaleChanged{Output: sc.name(), Scale: sc.scale}:
923+ default:
924+ }
925+ }
915 if err := c.publish(ctx); err != nil {926 if err := c.publish(ctx); err != nil {
916 return nil927 return nil
917 }928 }
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
@@ -468,6 +468,12 @@ func (OutputRemoved) outputEvent() {}
468468 // ConfigChanged carries config → core reloads.
469469 type ConfigChanged struct{ Config Config }
470470
471+// ScaleChanged carries core → persistence a scale set by a scale bind.
472+type ScaleChanged struct {
473+ Output string
474+ Scale float64
475+}
476+
471477 // ClientCommand carries core → wayland commands.
472478 type ClientCommand interface{ clientCommand() }
473479
@@ -468,6 +468,12 @@ func (OutputRemoved) outputEvent() {}
468 // ConfigChanged carries config → core reloads.468 // ConfigChanged carries config → core reloads.
469 type ConfigChanged struct{ Config Config }469 type ConfigChanged struct{ Config Config }
470 470
471+// ScaleChanged carries core → persistence a scale set by a scale bind.
472+type ScaleChanged struct {
473+ Output string
474+ Scale float64
475+}
476+
471 // ClientCommand carries core → wayland commands.477 // ClientCommand carries core → wayland commands.
472 type ClientCommand interface{ clientCommand() }478 type ClientCommand interface{ clientCommand() }
473 479

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 25

Tags 10

↑↓ moveenter openesc close