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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +732 -4
M .mockery.yml +2 -0
| @@ -13,6 +13,8 @@ packages: | |||
| 13 | 13 | pkgname: portsmocks | |
| 14 | 14 | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' | |
| 15 | 15 | interfaces: | |
| 16 | + | Notifier: | |
| 17 | + | OutputScaleStore: | |
| 16 | 18 | Renderer: | |
| 17 | 19 | Seat: | |
| 18 | 20 | # Unexported adapter seams: mocks live next to the code, in test files. | |
| @@ -13,6 +13,8 @@ packages: | |||||
| 13 | pkgname: portsmocks | 13 | 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 | |||
| 85 | 85 | | `move-window-to-workspace <N>` / `-up/-down` | Move only the focused window | | |
| 86 | 86 | | `focus-monitor-left/right` | Focus the neighbor monitor | | |
| 87 | 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 | 89 | | `quit` | Exit NeferWL | | |
| 90 | 90 | ||
| 91 | 91 | ### 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 binds | 91 | ### Default binds | ||
M examples/config +2 -1
| @@ -64,7 +64,8 @@ focus.follow-move = off | |||
| 64 | 64 | # output.DP-2 = 5120x2160@165.058 | |
| 65 | 65 | # output.HDMI-A-1 = off | |
| 66 | 66 | # 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 | 69 | # output.DP-2.scale = 4/3 | |
| 69 | 70 | ||
| 70 | 71 | render.direct-scanout = on | |
| @@ -64,7 +64,8 @@ focus.follow-move = off | |||||
| 64 | # output.DP-2 = 5120x2160@165.058 | 64 | # output.DP-2 = 5120x2160@165.058 | ||
| 65 | # output.HDMI-A-1 = off | 65 | # output.HDMI-A-1 = off | ||
| 66 | # Scale (HiDPI), 1 to 4, like 1.5 or 4/3. Default 1. scale-up and scale-down | 66 | # 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/3 | 69 | # output.DP-2.scale = 4/3 | ||
| 69 | 70 | ||||
| 70 | render.direct-scanout = on | 71 | 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) | |||
| 99 | 99 | appliedOutput := make(chan ports.OutputApplied, 8) | |
| 100 | 100 | backendConfig := make(chan ports.Config, 8) | |
| 101 | 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 | 108 | c, err := core.New(opts.Config, ch) | |
| 104 | 109 | if err != nil { | |
| 105 | 110 | return err | |
| @@ -152,6 +157,13 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 152 | 157 | defer workers.Done() | |
| 153 | 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 | 167 | filtered := make(chan ports.ConfigChanged, 8) | |
| 156 | 168 | workers.Add(1) | |
| 157 | 169 | 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 err | 110 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "image" | |
| 7 | 7 | "math" | |
| 8 | + | "reflect" | |
| 8 | 9 | "slices" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -42,11 +43,59 @@ func (o *outputOverrides) effective() ports.Config { | |||
| 42 | 43 | } | |
| 43 | 44 | 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 | 50 | func (o *outputOverrides) reload(file ports.Config) ports.Config { | |
| 51 | + | scales, onlyScales := scaleChanges(o.file, file) | |
| 46 | 52 | 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 | 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 | 99 | func (o *outputOverrides) validate(req ports.OutputApply) error { | |
| 51 | 100 | if len(req.Heads) != len(o.heads.Heads) { | |
| 52 | 101 | 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 cfg | 44 | 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 = file | 52 | 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) { | |||
| 210 | 210 | <-backend | |
| 211 | 211 | 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 | + | } | |
| @@ -210,3 +210,26 @@ func TestReloadDuringPendingApply(t *testing.T) { | |||||
| 210 | <-backend | 210 | <-backend | ||
| 211 | backendDone <- nil | 211 | 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 { | |||
| 37 | 37 | // State, when set, receives a snapshot for scripts whenever it changes, | |
| 38 | 38 | // latest first (capacity 1, drained like Scenes). | |
| 39 | 39 | 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 | 43 | // Terminal, when set, keeps a window on every workspace on screen: an | |
| 41 | 44 | // empty one gets the configured terminal (the terminal is the desktop). | |
| 42 | 45 | Terminal bool | |
| @@ -910,8 +913,16 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 910 | 913 | dir = -1 | |
| 911 | 914 | } | |
| 912 | 915 | sc := c.cur() | |
| 916 | + | prev := sc.scale | |
| 913 | 917 | sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir)) | |
| 914 | 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 | 926 | if err := c.publish(ctx); err != nil { | |
| 916 | 927 | return nil | |
| 917 | 928 | } | |
| @@ -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.State | 39 | 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: an | 43 | // 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 bool | 45 | Terminal bool | ||
| @@ -910,8 +913,16 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 910 | dir = -1 | 913 | 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 nil | 927 | 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() {} | |||
| 468 | 468 | // ConfigChanged carries config → core reloads. | |
| 469 | 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 | 477 | // ClientCommand carries core → wayland commands. | |
| 472 | 478 | type ClientCommand interface{ clientCommand() } | |
| 473 | 479 | ||
| @@ -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 | ||||