detached 32310af

refactor(core): move output override validation and merge policy from app to core

commit
32310af6d5e3b53e31bae77179173b051d55c1d2
author
brice · · 2026-10-08 05:34 UTC
parent
974d42e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +356 -309
M internal/app/outputmanagement.go +11 -169
@@ -2,168 +2,12 @@ package app
22
33 import (
44 "errors"
5- "fmt"
6- "image"
7- "maps"
8- "math"
9- "reflect"
10- "slices"
115
6+ "github.com/bnema/neferwl/internal/core"
127 "github.com/bnema/neferwl/internal/ports"
138 "github.com/bnema/zerowrap"
149 )
1510
16-// outputOverrides holds runtime-only output settings. Only outputApply uses
17-// it; the file configuration is never mutated.
18-type outputOverrides struct {
19- file ports.Config
20- overrides map[string]ports.OutputConfig
21- heads ports.OutputHeads
22- headless bool
23-}
24-
25-func newOutputOverrides(file ports.Config, headless bool) *outputOverrides {
26- return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
27-}
28-func (o *outputOverrides) effective() ports.Config {
29- cfg := o.file
30- cfg.Outputs = slices.Clone(o.file.Outputs)
31- // Preserve file order and backend connection order; map iteration would
32- // make automatic placement change unpredictably.
33- for _, head := range o.heads.Heads {
34- entry, ok := o.overrides[head.Info.Name]
35- if !ok {
36- continue
37- }
38- index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
39- if index < 0 {
40- cfg.Outputs = append(cfg.Outputs, entry)
41- } else {
42- cfg.Outputs[index] = entry
43- }
44- }
45- return cfg
46-}
47-
48-// reload replaces the file configuration. Runtime overrides are dropped,
49-// unless only output scales changed (a saved scale bind): then they stay and
50-// take the new file scales.
51-func (o *outputOverrides) reload(file ports.Config) ports.Config {
52- scales, onlyScales := scaleChanges(o.file, file)
53- o.file = file
54- if !onlyScales {
55- clear(o.overrides)
56- return o.effective()
57- }
58- for name, scale := range scales {
59- if entry, ok := o.overrides[name]; ok {
60- entry.Scale = scale
61- o.overrides[name] = entry
62- }
63- }
64- return o.effective()
65-}
66-
67-// scaleChanges lists the output scales that differ from old to cur, and
68-// reports whether nothing else differs.
69-func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
70- scaleOf := func(c ports.Config, name string) float64 {
71- for _, o := range c.Outputs {
72- if o.Name == name {
73- return o.Scale
74- }
75- }
76- return 0
77- }
78- // Without scales, an output.<name>.scale-only entry is empty: drop it.
79- strip := func(c ports.Config) ports.Config {
80- c.Outputs = slices.Clone(c.Outputs)
81- for i := range c.Outputs {
82- c.Outputs[i].Scale = 0
83- }
84- c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
85- return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
86- })
87- return c
88- }
89- if !reflect.DeepEqual(strip(old), strip(cur)) {
90- return nil, false
91- }
92- scales := map[string]float64{}
93- for _, o := range cur.Outputs {
94- if o.Scale != scaleOf(old, o.Name) {
95- scales[o.Name] = o.Scale
96- }
97- }
98- return scales, true
99-}
100-func (o *outputOverrides) validate(req ports.OutputApply) error {
101- if len(req.Heads) != len(o.heads.Heads) {
102- return fmt.Errorf("incomplete head configuration")
103- }
104- seen := map[string]bool{}
105- for _, change := range req.Heads {
106- if seen[change.Name] {
107- return fmt.Errorf("duplicate head %s", change.Name)
108- }
109- seen[change.Name] = true
110- index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
111- if index < 0 {
112- return fmt.Errorf("unknown head %s", change.Name)
113- }
114- current := o.heads.Heads[index]
115- if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
116- return fmt.Errorf("invalid scale on %s", change.Name)
117- }
118- if change.Transform != nil && *change.Transform > 7 {
119- return fmt.Errorf("invalid transform on %s", change.Name)
120- }
121- if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
122- return fmt.Errorf("headless mode and enabled state are fixed")
123- }
124- if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
125- return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
126- }) {
127- return fmt.Errorf("unknown mode on %s", change.Name)
128- }
129- }
130- return nil
131-}
132-func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
133- if err := o.validate(req); err != nil {
134- return ports.Config{}, err
135- }
136- if req.Test {
137- return o.effective(), nil
138- }
139- for _, h := range req.Heads {
140- entry, ok := o.overrides[h.Name]
141- if !ok {
142- index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
143- if index >= 0 {
144- entry = o.file.Outputs[index]
145- } else {
146- entry.Name = h.Name
147- }
148- }
149- entry.Off = !h.Enabled
150- if h.Scale > 0 {
151- entry.Scale = h.Scale
152- }
153- if h.Transform != nil {
154- entry.Transform = *h.Transform
155- }
156- if h.Pos != nil {
157- entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
158- }
159- if h.Mode != nil {
160- entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
161- }
162- o.overrides[h.Name] = entry
163- }
164- return o.effective(), nil
165-}
166-
16711 // errApplySuperseded answers a request whose configuration a reload replaced.
16812 var errApplySuperseded = errors.New("output apply superseded by reload")
16913
@@ -174,7 +18,7 @@ var errApplySuperseded = errors.New("output apply superseded by reload")
17418 // configuration, Wayland replies and inventory) with sends that never block
17519 // the loop.
17620 type outputApply struct {
177- state *outputOverrides
21+ state *core.OutputOverrides
17822 log zerowrap.Logger
17923 // running is set while the backend applies a configuration. The next
18024 // one waits in start until it finishes: configurations never overlap.
@@ -195,20 +39,20 @@ type outputApply struct {
19539 headsDue bool
19640 }
19741
198-func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {
42+func newOutputApply(state *core.OutputOverrides, log zerowrap.Logger) *outputApply {
19943 return &outputApply{state: state, log: log}
20044 }
20145
20246 // reload replaces the file configuration and supersedes the pending request.
20347 // Only the latest reload needs backend work.
20448 func (a *outputApply) reload(file ports.Config) {
205- cfg := a.state.reload(file)
49+ cfg := a.state.Reload(file)
20650 a.setConfig(cfg)
20751 if a.req != nil {
20852 a.reply(a.req.ID, errApplySuperseded)
20953 a.req = nil
21054 }
211- if !a.state.headless {
55+ if !a.state.Headless() {
21256 a.due(cfg)
21357 }
21458 }
@@ -216,7 +60,7 @@ func (a *outputApply) reload(file ports.Config) {
21660 // heads records the backend inventory: it validates requests and waits for
21761 // Wayland in the outbox, latest only.
21862 func (a *outputApply) heads(heads ports.OutputHeads) {
219- a.state.heads = heads
63+ a.state.SetHeads(heads)
22064 a.headsDue = true
22165 }
22266
@@ -227,15 +71,14 @@ func (a *outputApply) request(req ports.OutputApply) {
22771 a.reply(req.ID, errors.New("output apply already pending"))
22872 return
22973 }
230- previous := make(map[string]ports.OutputConfig, len(a.state.overrides))
231- maps.Copy(previous, a.state.overrides)
232- cfg, err := a.state.apply(req)
74+ previous := a.state.Snapshot()
75+ cfg, err := a.state.Apply(req)
23376 if err != nil || req.Test {
23477 a.reply(req.ID, err)
23578 return
23679 }
23780 a.setConfig(cfg)
238- if a.state.headless {
81+ if a.state.Headless() {
23982 a.reply(req.ID, nil)
24083 return
24184 }
@@ -277,8 +120,7 @@ func (a *outputApply) finished(err error) {
277120 return
278121 }
279122 if err != nil {
280- a.state.overrides = a.previous
281- cfg := a.state.effective()
123+ cfg := a.state.Restore(a.previous)
282124 a.setConfig(cfg)
283125 a.rollback, a.failure = true, err
284126 a.due(cfg)
@@ -338,7 +180,7 @@ func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.Output
338180 if !a.headsDue {
339181 return nil, ports.OutputHeads{}
340182 }
341- return ch, a.state.heads
183+ return ch, a.state.Heads()
342184 }
343185
344186 // headsSent records that Wayland received the latest inventory.
@@ -2,168 +2,12 @@ package app
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "fmt"
6- "image"
7- "maps"
8- "math"
9- "reflect"
10- "slices"
11 5
6+ "github.com/bnema/neferwl/internal/core"
12 "github.com/bnema/neferwl/internal/ports"7 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
14 )9 )
15 10
16-// outputOverrides holds runtime-only output settings. Only outputApply uses
17-// it; the file configuration is never mutated.
18-type outputOverrides struct {
19- file ports.Config
20- overrides map[string]ports.OutputConfig
21- heads ports.OutputHeads
22- headless bool
23-}
24-
25-func newOutputOverrides(file ports.Config, headless bool) *outputOverrides {
26- return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
27-}
28-func (o *outputOverrides) effective() ports.Config {
29- cfg := o.file
30- cfg.Outputs = slices.Clone(o.file.Outputs)
31- // Preserve file order and backend connection order; map iteration would
32- // make automatic placement change unpredictably.
33- for _, head := range o.heads.Heads {
34- entry, ok := o.overrides[head.Info.Name]
35- if !ok {
36- continue
37- }
38- index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
39- if index < 0 {
40- cfg.Outputs = append(cfg.Outputs, entry)
41- } else {
42- cfg.Outputs[index] = entry
43- }
44- }
45- return cfg
46-}
47-
48-// reload replaces the file configuration. Runtime overrides are dropped,
49-// unless only output scales changed (a saved scale bind): then they stay and
50-// take the new file scales.
51-func (o *outputOverrides) reload(file ports.Config) ports.Config {
52- scales, onlyScales := scaleChanges(o.file, file)
53- o.file = file
54- if !onlyScales {
55- clear(o.overrides)
56- return o.effective()
57- }
58- for name, scale := range scales {
59- if entry, ok := o.overrides[name]; ok {
60- entry.Scale = scale
61- o.overrides[name] = entry
62- }
63- }
64- return o.effective()
65-}
66-
67-// scaleChanges lists the output scales that differ from old to cur, and
68-// reports whether nothing else differs.
69-func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
70- scaleOf := func(c ports.Config, name string) float64 {
71- for _, o := range c.Outputs {
72- if o.Name == name {
73- return o.Scale
74- }
75- }
76- return 0
77- }
78- // Without scales, an output.<name>.scale-only entry is empty: drop it.
79- strip := func(c ports.Config) ports.Config {
80- c.Outputs = slices.Clone(c.Outputs)
81- for i := range c.Outputs {
82- c.Outputs[i].Scale = 0
83- }
84- c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
85- return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
86- })
87- return c
88- }
89- if !reflect.DeepEqual(strip(old), strip(cur)) {
90- return nil, false
91- }
92- scales := map[string]float64{}
93- for _, o := range cur.Outputs {
94- if o.Scale != scaleOf(old, o.Name) {
95- scales[o.Name] = o.Scale
96- }
97- }
98- return scales, true
99-}
100-func (o *outputOverrides) validate(req ports.OutputApply) error {
101- if len(req.Heads) != len(o.heads.Heads) {
102- return fmt.Errorf("incomplete head configuration")
103- }
104- seen := map[string]bool{}
105- for _, change := range req.Heads {
106- if seen[change.Name] {
107- return fmt.Errorf("duplicate head %s", change.Name)
108- }
109- seen[change.Name] = true
110- index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
111- if index < 0 {
112- return fmt.Errorf("unknown head %s", change.Name)
113- }
114- current := o.heads.Heads[index]
115- if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
116- return fmt.Errorf("invalid scale on %s", change.Name)
117- }
118- if change.Transform != nil && *change.Transform > 7 {
119- return fmt.Errorf("invalid transform on %s", change.Name)
120- }
121- if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
122- return fmt.Errorf("headless mode and enabled state are fixed")
123- }
124- if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
125- return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
126- }) {
127- return fmt.Errorf("unknown mode on %s", change.Name)
128- }
129- }
130- return nil
131-}
132-func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
133- if err := o.validate(req); err != nil {
134- return ports.Config{}, err
135- }
136- if req.Test {
137- return o.effective(), nil
138- }
139- for _, h := range req.Heads {
140- entry, ok := o.overrides[h.Name]
141- if !ok {
142- index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
143- if index >= 0 {
144- entry = o.file.Outputs[index]
145- } else {
146- entry.Name = h.Name
147- }
148- }
149- entry.Off = !h.Enabled
150- if h.Scale > 0 {
151- entry.Scale = h.Scale
152- }
153- if h.Transform != nil {
154- entry.Transform = *h.Transform
155- }
156- if h.Pos != nil {
157- entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
158- }
159- if h.Mode != nil {
160- entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
161- }
162- o.overrides[h.Name] = entry
163- }
164- return o.effective(), nil
165-}
166-
167 // errApplySuperseded answers a request whose configuration a reload replaced.11 // errApplySuperseded answers a request whose configuration a reload replaced.
168 var errApplySuperseded = errors.New("output apply superseded by reload")12 var errApplySuperseded = errors.New("output apply superseded by reload")
169 13
@@ -174,7 +18,7 @@ var errApplySuperseded = errors.New("output apply superseded by reload")
174 // configuration, Wayland replies and inventory) with sends that never block18 // configuration, Wayland replies and inventory) with sends that never block
175 // the loop.19 // the loop.
176 type outputApply struct {20 type outputApply struct {
177- state *outputOverrides21+ state *core.OutputOverrides
178 log zerowrap.Logger22 log zerowrap.Logger
179 // running is set while the backend applies a configuration. The next23 // running is set while the backend applies a configuration. The next
180 // one waits in start until it finishes: configurations never overlap.24 // one waits in start until it finishes: configurations never overlap.
@@ -195,20 +39,20 @@ type outputApply struct {
195 headsDue bool39 headsDue bool
196 }40 }
197 41
198-func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {42+func newOutputApply(state *core.OutputOverrides, log zerowrap.Logger) *outputApply {
199 return &outputApply{state: state, log: log}43 return &outputApply{state: state, log: log}
200 }44 }
201 45
202 // reload replaces the file configuration and supersedes the pending request.46 // reload replaces the file configuration and supersedes the pending request.
203 // Only the latest reload needs backend work.47 // Only the latest reload needs backend work.
204 func (a *outputApply) reload(file ports.Config) {48 func (a *outputApply) reload(file ports.Config) {
205- cfg := a.state.reload(file)49+ cfg := a.state.Reload(file)
206 a.setConfig(cfg)50 a.setConfig(cfg)
207 if a.req != nil {51 if a.req != nil {
208 a.reply(a.req.ID, errApplySuperseded)52 a.reply(a.req.ID, errApplySuperseded)
209 a.req = nil53 a.req = nil
210 }54 }
211- if !a.state.headless {55+ if !a.state.Headless() {
212 a.due(cfg)56 a.due(cfg)
213 }57 }
214 }58 }
@@ -216,7 +60,7 @@ func (a *outputApply) reload(file ports.Config) {
216 // heads records the backend inventory: it validates requests and waits for60 // heads records the backend inventory: it validates requests and waits for
217 // Wayland in the outbox, latest only.61 // Wayland in the outbox, latest only.
218 func (a *outputApply) heads(heads ports.OutputHeads) {62 func (a *outputApply) heads(heads ports.OutputHeads) {
219- a.state.heads = heads63+ a.state.SetHeads(heads)
220 a.headsDue = true64 a.headsDue = true
221 }65 }
222 66
@@ -227,15 +71,14 @@ func (a *outputApply) request(req ports.OutputApply) {
227 a.reply(req.ID, errors.New("output apply already pending"))71 a.reply(req.ID, errors.New("output apply already pending"))
228 return72 return
229 }73 }
230- previous := make(map[string]ports.OutputConfig, len(a.state.overrides))74+ previous := a.state.Snapshot()
231- maps.Copy(previous, a.state.overrides)75+ cfg, err := a.state.Apply(req)
232- cfg, err := a.state.apply(req)
233 if err != nil || req.Test {76 if err != nil || req.Test {
234 a.reply(req.ID, err)77 a.reply(req.ID, err)
235 return78 return
236 }79 }
237 a.setConfig(cfg)80 a.setConfig(cfg)
238- if a.state.headless {81+ if a.state.Headless() {
239 a.reply(req.ID, nil)82 a.reply(req.ID, nil)
240 return83 return
241 }84 }
@@ -277,8 +120,7 @@ func (a *outputApply) finished(err error) {
277 return120 return
278 }121 }
279 if err != nil {122 if err != nil {
280- a.state.overrides = a.previous123+ cfg := a.state.Restore(a.previous)
281- cfg := a.state.effective()
282 a.setConfig(cfg)124 a.setConfig(cfg)
283 a.rollback, a.failure = true, err125 a.rollback, a.failure = true, err
284 a.due(cfg)126 a.due(cfg)
@@ -338,7 +180,7 @@ func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.Output
338 if !a.headsDue {180 if !a.headsDue {
339 return nil, ports.OutputHeads{}181 return nil, ports.OutputHeads{}
340 }182 }
341- return ch, a.state.heads183+ return ch, a.state.Heads()
342 }184 }
343 185
344 // headsSent records that Wayland received the latest inventory.186 // headsSent records that Wayland received the latest inventory.
M internal/app/outputmanagement_test.go +3 -139
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/core"
1011 "github.com/bnema/neferwl/internal/ports"
1112 )
1213
@@ -18,7 +19,7 @@ type applyRig struct {
1819
1920 func newApplyRig(t *testing.T, headless bool) applyRig {
2021 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
21- a := newOutputApply(newOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
22+ a := newOutputApply(core.NewOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
2223 a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
2324 return applyRig{t, a}
2425 }
@@ -113,7 +114,7 @@ func TestOutputApply(t *testing.T) {
113114 if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
114115 t.Fatalf("replies: %+v, want %v", got, tc.failure)
115116 }
116- if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 {
117+ if tc.failure != nil && len(r.a.state.Effective().Outputs) != 0 {
117118 t.Fatal("override survived failure")
118119 }
119120 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
@@ -176,20 +177,6 @@ func TestOutputApplyOutbox(t *testing.T) {
176177 }
177178 }
178179
179-func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
180- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
181- state := newOutputOverrides(ports.Config{}, true)
182- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
183- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
184- t.Fatal("headless disable accepted")
185- }
186- state.headless = false
187- cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
188- if err != nil || !cfg.Outputs[0].Off {
189- t.Fatalf("disable: %+v, %v", cfg, err)
190- }
191-}
192-
193180 // A reload supersedes a request the backend is applying: the request fails at
194181 // once, and a late failure of its configuration cannot roll back the reload.
195182 func TestReloadDuringPendingApply(t *testing.T) {
@@ -283,126 +270,3 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) {
283270 t.Fatalf("headless reply: %+v", got)
284271 }
285272 }
286-
287-// A reload that only changes output scales (a saved scale bind) keeps the
288-// runtime overrides; any other change drops them.
289-func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
290- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
291- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
292- state := newOutputOverrides(file, false)
293- 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}}}}
294- 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 {
295- t.Fatal(err)
296- }
297- scaled := file
298- scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
299- cfg := state.reload(scaled)
300- if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
301- t.Fatalf("scale-only reload: %+v", cfg.Outputs)
302- }
303- other := scaled
304- other.Layout.Gaps = 4
305- if cfg := state.reload(other); cfg.Outputs[0].Pos != nil {
306- t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307- }
308-}
309-
310-// A transform change takes the full reload path; a scale change alone does not.
311-func TestScaleChangesTransform(t *testing.T) {
312- old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
313- cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
314- if _, only := scaleChanges(old, cur); only {
315- t.Fatal("a transform change must take the full reload path")
316- }
317- cur.Outputs[0].Transform = 0
318- cur.Outputs[0].Scale = 1.5
319- if _, only := scaleChanges(old, cur); !only {
320- t.Fatal("a scale-only change stays a scale change")
321- }
322-}
323-
324-// A runtime transform lands in the effective configuration and survives a
325-// reload that only changes file scales.
326-func TestOutputApplyTransform(t *testing.T) {
327- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
328- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
329- state := newOutputOverrides(file, false)
330- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
331- tr := ports.BufferTransform(3)
332- cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
333- if err != nil {
334- t.Fatal(err)
335- }
336- if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
337- t.Fatalf("applied transform: %+v", cfg.Outputs)
338- }
339- scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
340- cfg = state.reload(scaled)
341- if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
342- t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
343- }
344- // Without a transform in the change, the current one stays.
345- cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
346- if err != nil || cfg.Outputs[0].Transform != 3 {
347- t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
348- }
349- bad := ports.BufferTransform(8)
350- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
351- t.Fatal("transform 8 accepted")
352- }
353-}
354-
355-// A runtime position never replaces a file relation: it only takes
356-// precedence over it in core, and the relation is followed again once the
357-// override is dropped.
358-func TestOutputApplyKeepsFileAnchor(t *testing.T) {
359- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
360- anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
361- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
362- o := newOutputOverrides(file, false)
363- head := func(name string) ports.OutputHead {
364- return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
365- }
366- o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
367- change := func(name string, x int) ports.HeadChange {
368- return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
369- }
370- byName := func(cfg ports.Config, name string) ports.OutputConfig {
371- t.Helper()
372- var found []ports.OutputConfig
373- for _, v := range cfg.Outputs {
374- if v.Name == name {
375- found = append(found, v)
376- }
377- }
378- if len(found) != 1 {
379- t.Fatalf("%s entries: %+v", name, cfg.Outputs)
380- }
381- return found[0]
382- }
383-
384- cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
385- if err != nil {
386- t.Fatal(err)
387- }
388- dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
389- if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
390- t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
391- }
392-
393- cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
394- if err != nil {
395- t.Fatal(err)
396- }
397- dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
398- if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
399- t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
400- }
401-
402- // A non scale-only reload drops the overrides: the file relation is back alone.
403- file.Keyboard.RepeatRate = 33
404- cfg = o.reload(file)
405- if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
406- t.Fatalf("after reload: %+v", cfg.Outputs)
407- }
408-}
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/neferwl/internal/adapters/logging"9 "github.com/bnema/neferwl/internal/adapters/logging"
10+ "github.com/bnema/neferwl/internal/core"
10 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
11 )12 )
12 13
@@ -18,7 +19,7 @@ type applyRig struct {
18 19
19 func newApplyRig(t *testing.T, headless bool) applyRig {20 func newApplyRig(t *testing.T, headless bool) applyRig {
20 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}21 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
21- a := newOutputApply(newOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))22+ a := newOutputApply(core.NewOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
22 a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})23 a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
23 return applyRig{t, a}24 return applyRig{t, a}
24 }25 }
@@ -113,7 +114,7 @@ func TestOutputApply(t *testing.T) {
113 if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {114 if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
114 t.Fatalf("replies: %+v, want %v", got, tc.failure)115 t.Fatalf("replies: %+v, want %v", got, tc.failure)
115 }116 }
116- if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 {117+ if tc.failure != nil && len(r.a.state.Effective().Outputs) != 0 {
117 t.Fatal("override survived failure")118 t.Fatal("override survived failure")
118 }119 }
119 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})120 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
@@ -176,20 +177,6 @@ func TestOutputApplyOutbox(t *testing.T) {
176 }177 }
177 }178 }
178 179
179-func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
180- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
181- state := newOutputOverrides(ports.Config{}, true)
182- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
183- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
184- t.Fatal("headless disable accepted")
185- }
186- state.headless = false
187- cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
188- if err != nil || !cfg.Outputs[0].Off {
189- t.Fatalf("disable: %+v, %v", cfg, err)
190- }
191-}
192-
193 // A reload supersedes a request the backend is applying: the request fails at180 // A reload supersedes a request the backend is applying: the request fails at
194 // once, and a late failure of its configuration cannot roll back the reload.181 // once, and a late failure of its configuration cannot roll back the reload.
195 func TestReloadDuringPendingApply(t *testing.T) {182 func TestReloadDuringPendingApply(t *testing.T) {
@@ -283,126 +270,3 @@ func TestOutputApplyAnsweredAtOnce(t *testing.T) {
283 t.Fatalf("headless reply: %+v", got)270 t.Fatalf("headless reply: %+v", got)
284 }271 }
285 }272 }
286-
287-// A reload that only changes output scales (a saved scale bind) keeps the
288-// runtime overrides; any other change drops them.
289-func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
290- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
291- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
292- state := newOutputOverrides(file, false)
293- 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}}}}
294- 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 {
295- t.Fatal(err)
296- }
297- scaled := file
298- scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
299- cfg := state.reload(scaled)
300- if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
301- t.Fatalf("scale-only reload: %+v", cfg.Outputs)
302- }
303- other := scaled
304- other.Layout.Gaps = 4
305- if cfg := state.reload(other); cfg.Outputs[0].Pos != nil {
306- t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307- }
308-}
309-
310-// A transform change takes the full reload path; a scale change alone does not.
311-func TestScaleChangesTransform(t *testing.T) {
312- old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
313- cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
314- if _, only := scaleChanges(old, cur); only {
315- t.Fatal("a transform change must take the full reload path")
316- }
317- cur.Outputs[0].Transform = 0
318- cur.Outputs[0].Scale = 1.5
319- if _, only := scaleChanges(old, cur); !only {
320- t.Fatal("a scale-only change stays a scale change")
321- }
322-}
323-
324-// A runtime transform lands in the effective configuration and survives a
325-// reload that only changes file scales.
326-func TestOutputApplyTransform(t *testing.T) {
327- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
328- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
329- state := newOutputOverrides(file, false)
330- state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
331- tr := ports.BufferTransform(3)
332- cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
333- if err != nil {
334- t.Fatal(err)
335- }
336- if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
337- t.Fatalf("applied transform: %+v", cfg.Outputs)
338- }
339- scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
340- cfg = state.reload(scaled)
341- if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
342- t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
343- }
344- // Without a transform in the change, the current one stays.
345- cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
346- if err != nil || cfg.Outputs[0].Transform != 3 {
347- t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
348- }
349- bad := ports.BufferTransform(8)
350- if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
351- t.Fatal("transform 8 accepted")
352- }
353-}
354-
355-// A runtime position never replaces a file relation: it only takes
356-// precedence over it in core, and the relation is followed again once the
357-// override is dropped.
358-func TestOutputApplyKeepsFileAnchor(t *testing.T) {
359- mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
360- anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
361- file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
362- o := newOutputOverrides(file, false)
363- head := func(name string) ports.OutputHead {
364- return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
365- }
366- o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
367- change := func(name string, x int) ports.HeadChange {
368- return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
369- }
370- byName := func(cfg ports.Config, name string) ports.OutputConfig {
371- t.Helper()
372- var found []ports.OutputConfig
373- for _, v := range cfg.Outputs {
374- if v.Name == name {
375- found = append(found, v)
376- }
377- }
378- if len(found) != 1 {
379- t.Fatalf("%s entries: %+v", name, cfg.Outputs)
380- }
381- return found[0]
382- }
383-
384- cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
385- if err != nil {
386- t.Fatal(err)
387- }
388- dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
389- if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
390- t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
391- }
392-
393- cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
394- if err != nil {
395- t.Fatal(err)
396- }
397- dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
398- if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
399- t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
400- }
401-
402- // A non scale-only reload drops the overrides: the file relation is back alone.
403- file.Keyboard.RepeatRate = 33
404- cfg = o.reload(file)
405- if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
406- t.Fatalf("after reload: %+v", cfg.Outputs)
407- }
408-}
M internal/app/session.go +1 -1
@@ -335,7 +335,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
335335 go func() {
336336 defer workers.Done()
337337 outputIO := p.outputs(security)
338- apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
338+ apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
339339 if hw != nil {
340340 done <- safe("output", func() error {
341341 want := func(cfg ports.Config) drm.Want {
@@ -335,7 +335,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
335 go func() {335 go func() {
336 defer workers.Done()336 defer workers.Done()
337 outputIO := p.outputs(security)337 outputIO := p.outputs(security)
338- apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))338+ apply := newOutputApply(core.NewOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
339 if hw != nil {339 if hw != nil {
340 done <- safe("output", func() error {340 done <- safe("output", func() error {
341 want := func(cfg ports.Config) drm.Want {341 want := func(cfg ports.Config) drm.Want {
A internal/core/outputoverrides.go +196 -0
@@ -0,0 +1,196 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "image"
6+ "maps"
7+ "math"
8+ "reflect"
9+ "slices"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// OutputOverrides holds runtime-only output settings applied through output
15+// management on top of the file configuration, which is never mutated. Its
16+// owner is the backend loop; it is not safe for concurrent use.
17+type OutputOverrides struct {
18+ file ports.Config
19+ overrides map[string]ports.OutputConfig
20+ heads ports.OutputHeads
21+ headless bool
22+}
23+
24+// NewOutputOverrides starts with no override. Headless outputs keep their
25+// mode and enabled state.
26+func NewOutputOverrides(file ports.Config, headless bool) *OutputOverrides {
27+ return &OutputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
28+}
29+
30+// Headless reports whether the outputs are headless.
31+func (o *OutputOverrides) Headless() bool { return o.headless }
32+
33+// Heads is the backend inventory requests are validated against.
34+func (o *OutputOverrides) Heads() ports.OutputHeads { return o.heads }
35+
36+// SetHeads records the backend inventory.
37+func (o *OutputOverrides) SetHeads(heads ports.OutputHeads) { o.heads = heads }
38+
39+// Snapshot copies the current overrides for a later Restore.
40+func (o *OutputOverrides) Snapshot() map[string]ports.OutputConfig {
41+ previous := make(map[string]ports.OutputConfig, len(o.overrides))
42+ maps.Copy(previous, o.overrides)
43+ return previous
44+}
45+
46+// Restore puts back overrides taken by Snapshot and returns the effective
47+// configuration.
48+func (o *OutputOverrides) Restore(previous map[string]ports.OutputConfig) ports.Config {
49+ o.overrides = previous
50+ return o.Effective()
51+}
52+
53+// Effective is the file configuration with the overrides applied.
54+func (o *OutputOverrides) Effective() ports.Config {
55+ cfg := o.file
56+ cfg.Outputs = slices.Clone(o.file.Outputs)
57+ // Preserve file order and backend connection order; map iteration would
58+ // make automatic placement change unpredictably.
59+ for _, head := range o.heads.Heads {
60+ entry, ok := o.overrides[head.Info.Name]
61+ if !ok {
62+ continue
63+ }
64+ index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
65+ if index < 0 {
66+ cfg.Outputs = append(cfg.Outputs, entry)
67+ } else {
68+ cfg.Outputs[index] = entry
69+ }
70+ }
71+ return cfg
72+}
73+
74+// Reload replaces the file configuration. Runtime overrides are dropped,
75+// unless only output scales changed (a saved scale bind): then they stay and
76+// take the new file scales.
77+func (o *OutputOverrides) Reload(file ports.Config) ports.Config {
78+ scales, onlyScales := scaleChanges(o.file, file)
79+ o.file = file
80+ if !onlyScales {
81+ clear(o.overrides)
82+ return o.Effective()
83+ }
84+ for name, scale := range scales {
85+ if entry, ok := o.overrides[name]; ok {
86+ entry.Scale = scale
87+ o.overrides[name] = entry
88+ }
89+ }
90+ return o.Effective()
91+}
92+
93+// scaleChanges lists the output scales that differ from old to cur, and
94+// reports whether nothing else differs.
95+func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
96+ scaleOf := func(c ports.Config, name string) float64 {
97+ for _, o := range c.Outputs {
98+ if o.Name == name {
99+ return o.Scale
100+ }
101+ }
102+ return 0
103+ }
104+ // Without scales, an output.<name>.scale-only entry is empty: drop it.
105+ strip := func(c ports.Config) ports.Config {
106+ c.Outputs = slices.Clone(c.Outputs)
107+ for i := range c.Outputs {
108+ c.Outputs[i].Scale = 0
109+ }
110+ c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
111+ return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
112+ })
113+ return c
114+ }
115+ if !reflect.DeepEqual(strip(old), strip(cur)) {
116+ return nil, false
117+ }
118+ scales := map[string]float64{}
119+ for _, o := range cur.Outputs {
120+ if o.Scale != scaleOf(old, o.Name) {
121+ scales[o.Name] = o.Scale
122+ }
123+ }
124+ return scales, true
125+}
126+
127+func (o *OutputOverrides) validate(req ports.OutputApply) error {
128+ if len(req.Heads) != len(o.heads.Heads) {
129+ return fmt.Errorf("incomplete head configuration")
130+ }
131+ seen := map[string]bool{}
132+ for _, change := range req.Heads {
133+ if seen[change.Name] {
134+ return fmt.Errorf("duplicate head %s", change.Name)
135+ }
136+ seen[change.Name] = true
137+ index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
138+ if index < 0 {
139+ return fmt.Errorf("unknown head %s", change.Name)
140+ }
141+ current := o.heads.Heads[index]
142+ if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
143+ return fmt.Errorf("invalid scale on %s", change.Name)
144+ }
145+ if change.Transform != nil && *change.Transform > 7 {
146+ return fmt.Errorf("invalid transform on %s", change.Name)
147+ }
148+ if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
149+ return fmt.Errorf("headless mode and enabled state are fixed")
150+ }
151+ if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
152+ return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
153+ }) {
154+ return fmt.Errorf("unknown mode on %s", change.Name)
155+ }
156+ }
157+ return nil
158+}
159+
160+// Apply validates a request against the inventory and, unless it is a
161+// test, records its changes as overrides. It returns the effective
162+// configuration.
163+func (o *OutputOverrides) Apply(req ports.OutputApply) (ports.Config, error) {
164+ if err := o.validate(req); err != nil {
165+ return ports.Config{}, err
166+ }
167+ if req.Test {
168+ return o.Effective(), nil
169+ }
170+ for _, h := range req.Heads {
171+ entry, ok := o.overrides[h.Name]
172+ if !ok {
173+ index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
174+ if index >= 0 {
175+ entry = o.file.Outputs[index]
176+ } else {
177+ entry.Name = h.Name
178+ }
179+ }
180+ entry.Off = !h.Enabled
181+ if h.Scale > 0 {
182+ entry.Scale = h.Scale
183+ }
184+ if h.Transform != nil {
185+ entry.Transform = *h.Transform
186+ }
187+ if h.Pos != nil {
188+ entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
189+ }
190+ if h.Mode != nil {
191+ entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
192+ }
193+ o.overrides[h.Name] = entry
194+ }
195+ return o.Effective(), nil
196+}
@@ -0,0 +1,196 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "image"
6+ "maps"
7+ "math"
8+ "reflect"
9+ "slices"
10+
11+ "github.com/bnema/neferwl/internal/ports"
12+)
13+
14+// OutputOverrides holds runtime-only output settings applied through output
15+// management on top of the file configuration, which is never mutated. Its
16+// owner is the backend loop; it is not safe for concurrent use.
17+type OutputOverrides struct {
18+ file ports.Config
19+ overrides map[string]ports.OutputConfig
20+ heads ports.OutputHeads
21+ headless bool
22+}
23+
24+// NewOutputOverrides starts with no override. Headless outputs keep their
25+// mode and enabled state.
26+func NewOutputOverrides(file ports.Config, headless bool) *OutputOverrides {
27+ return &OutputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
28+}
29+
30+// Headless reports whether the outputs are headless.
31+func (o *OutputOverrides) Headless() bool { return o.headless }
32+
33+// Heads is the backend inventory requests are validated against.
34+func (o *OutputOverrides) Heads() ports.OutputHeads { return o.heads }
35+
36+// SetHeads records the backend inventory.
37+func (o *OutputOverrides) SetHeads(heads ports.OutputHeads) { o.heads = heads }
38+
39+// Snapshot copies the current overrides for a later Restore.
40+func (o *OutputOverrides) Snapshot() map[string]ports.OutputConfig {
41+ previous := make(map[string]ports.OutputConfig, len(o.overrides))
42+ maps.Copy(previous, o.overrides)
43+ return previous
44+}
45+
46+// Restore puts back overrides taken by Snapshot and returns the effective
47+// configuration.
48+func (o *OutputOverrides) Restore(previous map[string]ports.OutputConfig) ports.Config {
49+ o.overrides = previous
50+ return o.Effective()
51+}
52+
53+// Effective is the file configuration with the overrides applied.
54+func (o *OutputOverrides) Effective() ports.Config {
55+ cfg := o.file
56+ cfg.Outputs = slices.Clone(o.file.Outputs)
57+ // Preserve file order and backend connection order; map iteration would
58+ // make automatic placement change unpredictably.
59+ for _, head := range o.heads.Heads {
60+ entry, ok := o.overrides[head.Info.Name]
61+ if !ok {
62+ continue
63+ }
64+ index := slices.IndexFunc(cfg.Outputs, func(v ports.OutputConfig) bool { return v.Name == head.Info.Name })
65+ if index < 0 {
66+ cfg.Outputs = append(cfg.Outputs, entry)
67+ } else {
68+ cfg.Outputs[index] = entry
69+ }
70+ }
71+ return cfg
72+}
73+
74+// Reload replaces the file configuration. Runtime overrides are dropped,
75+// unless only output scales changed (a saved scale bind): then they stay and
76+// take the new file scales.
77+func (o *OutputOverrides) Reload(file ports.Config) ports.Config {
78+ scales, onlyScales := scaleChanges(o.file, file)
79+ o.file = file
80+ if !onlyScales {
81+ clear(o.overrides)
82+ return o.Effective()
83+ }
84+ for name, scale := range scales {
85+ if entry, ok := o.overrides[name]; ok {
86+ entry.Scale = scale
87+ o.overrides[name] = entry
88+ }
89+ }
90+ return o.Effective()
91+}
92+
93+// scaleChanges lists the output scales that differ from old to cur, and
94+// reports whether nothing else differs.
95+func scaleChanges(old, cur ports.Config) (map[string]float64, bool) {
96+ scaleOf := func(c ports.Config, name string) float64 {
97+ for _, o := range c.Outputs {
98+ if o.Name == name {
99+ return o.Scale
100+ }
101+ }
102+ return 0
103+ }
104+ // Without scales, an output.<name>.scale-only entry is empty: drop it.
105+ strip := func(c ports.Config) ports.Config {
106+ c.Outputs = slices.Clone(c.Outputs)
107+ for i := range c.Outputs {
108+ c.Outputs[i].Scale = 0
109+ }
110+ c.Outputs = slices.DeleteFunc(c.Outputs, func(o ports.OutputConfig) bool {
111+ return o == ports.OutputConfig{Name: o.Name, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}
112+ })
113+ return c
114+ }
115+ if !reflect.DeepEqual(strip(old), strip(cur)) {
116+ return nil, false
117+ }
118+ scales := map[string]float64{}
119+ for _, o := range cur.Outputs {
120+ if o.Scale != scaleOf(old, o.Name) {
121+ scales[o.Name] = o.Scale
122+ }
123+ }
124+ return scales, true
125+}
126+
127+func (o *OutputOverrides) validate(req ports.OutputApply) error {
128+ if len(req.Heads) != len(o.heads.Heads) {
129+ return fmt.Errorf("incomplete head configuration")
130+ }
131+ seen := map[string]bool{}
132+ for _, change := range req.Heads {
133+ if seen[change.Name] {
134+ return fmt.Errorf("duplicate head %s", change.Name)
135+ }
136+ seen[change.Name] = true
137+ index := slices.IndexFunc(o.heads.Heads, func(h ports.OutputHead) bool { return h.Info.Name == change.Name })
138+ if index < 0 {
139+ return fmt.Errorf("unknown head %s", change.Name)
140+ }
141+ current := o.heads.Heads[index]
142+ if change.Scale < 0 || change.Scale > 4 || (change.Scale > 0 && change.Scale < 1) || math.IsNaN(change.Scale) || math.IsInf(change.Scale, 0) {
143+ return fmt.Errorf("invalid scale on %s", change.Name)
144+ }
145+ if change.Transform != nil && *change.Transform > 7 {
146+ return fmt.Errorf("invalid transform on %s", change.Name)
147+ }
148+ if o.headless && (change.Enabled != current.Enabled || change.Mode != nil && (current.Current == nil || change.Mode.Width != current.Current.Width || change.Mode.Height != current.Current.Height || change.Mode.RefreshMilli != current.Current.RefreshMilli)) {
149+ return fmt.Errorf("headless mode and enabled state are fixed")
150+ }
151+ if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
152+ return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
153+ }) {
154+ return fmt.Errorf("unknown mode on %s", change.Name)
155+ }
156+ }
157+ return nil
158+}
159+
160+// Apply validates a request against the inventory and, unless it is a
161+// test, records its changes as overrides. It returns the effective
162+// configuration.
163+func (o *OutputOverrides) Apply(req ports.OutputApply) (ports.Config, error) {
164+ if err := o.validate(req); err != nil {
165+ return ports.Config{}, err
166+ }
167+ if req.Test {
168+ return o.Effective(), nil
169+ }
170+ for _, h := range req.Heads {
171+ entry, ok := o.overrides[h.Name]
172+ if !ok {
173+ index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
174+ if index >= 0 {
175+ entry = o.file.Outputs[index]
176+ } else {
177+ entry.Name = h.Name
178+ }
179+ }
180+ entry.Off = !h.Enabled
181+ if h.Scale > 0 {
182+ entry.Scale = h.Scale
183+ }
184+ if h.Transform != nil {
185+ entry.Transform = *h.Transform
186+ }
187+ if h.Pos != nil {
188+ entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
189+ }
190+ if h.Mode != nil {
191+ entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
192+ }
193+ o.overrides[h.Name] = entry
194+ }
195+ return o.Effective(), nil
196+}
A internal/core/outputoverrides_test.go +145 -0
@@ -0,0 +1,145 @@
1+package core
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
11+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
12+ state := NewOutputOverrides(ports.Config{}, true)
13+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
14+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
15+ t.Fatal("headless disable accepted")
16+ }
17+ state.headless = false
18+ cfg, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
19+ if err != nil || !cfg.Outputs[0].Off {
20+ t.Fatalf("disable: %+v, %v", cfg, err)
21+ }
22+}
23+
24+// A reload that only changes output scales (a saved scale bind) keeps the
25+// runtime overrides; any other change drops them.
26+func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
27+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
28+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
29+ state := NewOutputOverrides(file, false)
30+ 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}}}}
31+ 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 {
32+ t.Fatal(err)
33+ }
34+ scaled := file
35+ scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
36+ cfg := state.Reload(scaled)
37+ if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
38+ t.Fatalf("scale-only reload: %+v", cfg.Outputs)
39+ }
40+ other := scaled
41+ other.Layout.Gaps = 4
42+ if cfg := state.Reload(other); cfg.Outputs[0].Pos != nil {
43+ t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
44+ }
45+}
46+
47+// A transform change takes the full reload path; a scale change alone does not.
48+func TestScaleChangesTransform(t *testing.T) {
49+ old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
50+ cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
51+ if _, only := scaleChanges(old, cur); only {
52+ t.Fatal("a transform change must take the full reload path")
53+ }
54+ cur.Outputs[0].Transform = 0
55+ cur.Outputs[0].Scale = 1.5
56+ if _, only := scaleChanges(old, cur); !only {
57+ t.Fatal("a scale-only change stays a scale change")
58+ }
59+}
60+
61+// A runtime transform lands in the effective configuration and survives a
62+// reload that only changes file scales.
63+func TestOutputApplyTransform(t *testing.T) {
64+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
65+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
66+ state := NewOutputOverrides(file, false)
67+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
68+ tr := ports.BufferTransform(3)
69+ cfg, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
74+ t.Fatalf("applied transform: %+v", cfg.Outputs)
75+ }
76+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
77+ cfg = state.Reload(scaled)
78+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
79+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
80+ }
81+ // Without a transform in the change, the current one stays.
82+ cfg, err = state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
83+ if err != nil || cfg.Outputs[0].Transform != 3 {
84+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
85+ }
86+ bad := ports.BufferTransform(8)
87+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
88+ t.Fatal("transform 8 accepted")
89+ }
90+}
91+
92+// A runtime position never replaces a file relation: it only takes
93+// precedence over it in core, and the relation is followed again once the
94+// override is dropped.
95+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
96+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
97+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
98+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
99+ o := NewOutputOverrides(file, false)
100+ head := func(name string) ports.OutputHead {
101+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
102+ }
103+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
104+ change := func(name string, x int) ports.HeadChange {
105+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
106+ }
107+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
108+ t.Helper()
109+ var found []ports.OutputConfig
110+ for _, v := range cfg.Outputs {
111+ if v.Name == name {
112+ found = append(found, v)
113+ }
114+ }
115+ if len(found) != 1 {
116+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
117+ }
118+ return found[0]
119+ }
120+
121+ cfg, err := o.Apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
126+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
127+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
128+ }
129+
130+ cfg, err = o.Apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
135+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
136+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
137+ }
138+
139+ // A non scale-only reload drops the overrides: the file relation is back alone.
140+ file.Keyboard.RepeatRate = 33
141+ cfg = o.Reload(file)
142+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
143+ t.Fatalf("after reload: %+v", cfg.Outputs)
144+ }
145+}
@@ -0,0 +1,145 @@
1+package core
2+
3+import (
4+ "image"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
11+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
12+ state := NewOutputOverrides(ports.Config{}, true)
13+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
14+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
15+ t.Fatal("headless disable accepted")
16+ }
17+ state.headless = false
18+ cfg, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
19+ if err != nil || !cfg.Outputs[0].Off {
20+ t.Fatalf("disable: %+v, %v", cfg, err)
21+ }
22+}
23+
24+// A reload that only changes output scales (a saved scale bind) keeps the
25+// runtime overrides; any other change drops them.
26+func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
27+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
28+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
29+ state := NewOutputOverrides(file, false)
30+ 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}}}}
31+ 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 {
32+ t.Fatal(err)
33+ }
34+ scaled := file
35+ scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
36+ cfg := state.Reload(scaled)
37+ if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
38+ t.Fatalf("scale-only reload: %+v", cfg.Outputs)
39+ }
40+ other := scaled
41+ other.Layout.Gaps = 4
42+ if cfg := state.Reload(other); cfg.Outputs[0].Pos != nil {
43+ t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
44+ }
45+}
46+
47+// A transform change takes the full reload path; a scale change alone does not.
48+func TestScaleChangesTransform(t *testing.T) {
49+ old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
50+ cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
51+ if _, only := scaleChanges(old, cur); only {
52+ t.Fatal("a transform change must take the full reload path")
53+ }
54+ cur.Outputs[0].Transform = 0
55+ cur.Outputs[0].Scale = 1.5
56+ if _, only := scaleChanges(old, cur); !only {
57+ t.Fatal("a scale-only change stays a scale change")
58+ }
59+}
60+
61+// A runtime transform lands in the effective configuration and survives a
62+// reload that only changes file scales.
63+func TestOutputApplyTransform(t *testing.T) {
64+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
65+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
66+ state := NewOutputOverrides(file, false)
67+ state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
68+ tr := ports.BufferTransform(3)
69+ cfg, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
74+ t.Fatalf("applied transform: %+v", cfg.Outputs)
75+ }
76+ scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
77+ cfg = state.Reload(scaled)
78+ if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
79+ t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
80+ }
81+ // Without a transform in the change, the current one stays.
82+ cfg, err = state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
83+ if err != nil || cfg.Outputs[0].Transform != 3 {
84+ t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
85+ }
86+ bad := ports.BufferTransform(8)
87+ if _, err := state.Apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
88+ t.Fatal("transform 8 accepted")
89+ }
90+}
91+
92+// A runtime position never replaces a file relation: it only takes
93+// precedence over it in core, and the relation is followed again once the
94+// override is dropped.
95+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
96+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
97+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
98+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
99+ o := NewOutputOverrides(file, false)
100+ head := func(name string) ports.OutputHead {
101+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
102+ }
103+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
104+ change := func(name string, x int) ports.HeadChange {
105+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
106+ }
107+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
108+ t.Helper()
109+ var found []ports.OutputConfig
110+ for _, v := range cfg.Outputs {
111+ if v.Name == name {
112+ found = append(found, v)
113+ }
114+ }
115+ if len(found) != 1 {
116+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
117+ }
118+ return found[0]
119+ }
120+
121+ cfg, err := o.Apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
126+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
127+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
128+ }
129+
130+ cfg, err = o.Apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
135+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
136+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
137+ }
138+
139+ // A non scale-only reload drops the overrides: the file relation is back alone.
140+ file.Keyboard.RepeatRate = 33
141+ cfg = o.Reload(file)
142+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
143+ t.Fatalf("after reload: %+v", cfg.Outputs)
144+ }
145+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close