1package core
2
3import (
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.
17type 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.
26func 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.
31func (o *OutputOverrides) Headless() bool { return o.headless }
32
33// Heads is the backend inventory requests are validated against.
34func (o *OutputOverrides) Heads() ports.OutputHeads { return o.heads }
35
36// SetHeads records the backend inventory.
37func (o *OutputOverrides) SetHeads(heads ports.OutputHeads) { o.heads = heads }
38
39// Snapshot copies the current overrides for a later Restore.
40func (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.
48func (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.
54func (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.
77func (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.
95func 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
127func (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.
163func (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}