1package app
2
3import (
4 "errors"
5 "fmt"
6 "image"
7 "maps"
8 "math"
9 "reflect"
10 "slices"
11
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/zerowrap"
14)
15
16// outputOverrides holds runtime-only output settings. Only outputApply uses
17// it; the file configuration is never mutated.
18type outputOverrides struct {
19 file ports.Config
20 overrides map[string]ports.OutputConfig
21 heads ports.OutputHeads
22 headless bool
23}
24
25func newOutputOverrides(file ports.Config, headless bool) *outputOverrides {
26 return &outputOverrides{file: file, headless: headless, overrides: map[string]ports.OutputConfig{}}
27}
28func (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.
51func (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.
69func 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}
100func (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}
132func (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.
168var errApplySuperseded = errors.New("output apply superseded by reload")
169
170// outputApply owns output management from request to reply: runtime
171// overrides, supersede by reload, and rollback after a failed backend apply.
172// It belongs to the backend owner loop, which starts the configuration next
173// returns, reports each result through finished and drains the outbox (core
174// configuration, Wayland replies and inventory) with sends that never block
175// the loop.
176type outputApply struct {
177 state *outputOverrides
178 log zerowrap.Logger
179 // running is set while the backend applies a configuration. The next
180 // one waits in start until it finishes: configurations never overlap.
181 running bool
182 start ports.Config
183 pending bool
184 // req is the request behind the backend work; nil for a reload or after
185 // a reload superseded it.
186 req *ports.OutputApply
187 previous map[string]ports.OutputConfig
188 rollback bool
189 failure error
190 // config is the latest effective configuration core has not received.
191 config ports.ConfigChanged
192 publish bool
193 replies []ports.OutputApplied
194 // headsDue is set while Wayland has not received the latest inventory.
195 headsDue bool
196}
197
198func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {
199 return &outputApply{state: state, log: log}
200}
201
202// reload replaces the file configuration and supersedes the pending request.
203// Only the latest reload needs backend work.
204func (a *outputApply) reload(file ports.Config) {
205 cfg := a.state.reload(file)
206 a.setConfig(cfg)
207 if a.req != nil {
208 a.reply(a.req.ID, errApplySuperseded)
209 a.req = nil
210 }
211 if !a.state.headless {
212 a.due(cfg)
213 }
214}
215
216// heads records the backend inventory: it validates requests and waits for
217// Wayland in the outbox, latest only.
218func (a *outputApply) heads(heads ports.OutputHeads) {
219 a.state.heads = heads
220 a.headsDue = true
221}
222
223// request validates a protocol request. Test, invalid and headless requests
224// are answered at once; others wait for the backend.
225func (a *outputApply) request(req ports.OutputApply) {
226 if a.running || a.pending {
227 a.reply(req.ID, errors.New("output apply already pending"))
228 return
229 }
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)
233 if err != nil || req.Test {
234 a.reply(req.ID, err)
235 return
236 }
237 a.setConfig(cfg)
238 if a.state.headless {
239 a.reply(req.ID, nil)
240 return
241 }
242 a.req, a.previous, a.rollback, a.failure = &req, previous, false, nil
243 a.due(cfg)
244}
245
246// next returns the configuration the backend must start now, once. It
247// returns nothing while the backend applies the previous one.
248func (a *outputApply) next() (ports.Config, bool) {
249 if a.running || !a.pending {
250 return ports.Config{}, false
251 }
252 cfg := a.start
253 a.start, a.pending, a.running = ports.Config{}, false, true
254 return cfg, true
255}
256
257// finished records the backend result of the running configuration. When a
258// reload is waiting, the result belongs to a superseded configuration and is
259// dropped. After a failed request the previous overrides return and the
260// rollback configuration becomes due; the request is answered with the
261// original failure once the rollback is done.
262func (a *outputApply) finished(err error) {
263 if !a.running {
264 return
265 }
266 a.running = false
267 if a.pending {
268 return
269 }
270 req := a.req
271 if req == nil {
272 return
273 }
274 if a.rollback {
275 a.req = nil
276 a.reply(req.ID, a.failure)
277 return
278 }
279 if err != nil {
280 a.state.overrides = a.previous
281 cfg := a.state.effective()
282 a.setConfig(cfg)
283 a.rollback, a.failure = true, err
284 a.due(cfg)
285 return
286 }
287 a.req = nil
288 for _, h := range req.Heads {
289 e := a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale)
290 if h.Transform != nil {
291 e = e.Uint8("transform", uint8(*h.Transform))
292 }
293 e.Msg("output configuration applied")
294 }
295 a.reply(req.ID, nil)
296}
297
298// due replaces the configuration waiting to start: only the latest counts.
299func (a *outputApply) due(cfg ports.Config) {
300 a.start, a.pending = cfg, true
301}
302
303func (a *outputApply) setConfig(cfg ports.Config) {
304 a.config = ports.ConfigChanged{Config: cfg}
305 a.publish = true
306}
307
308func (a *outputApply) reply(id uint64, err error) {
309 if err != nil {
310 a.log.Warn().Err(err).Msg("output configuration failed")
311 }
312 a.replies = append(a.replies, ports.OutputApplied{ID: id, Err: err})
313}
314
315// configOut returns the core channel when a configuration waits, else nil so
316// the owner loop's send case stays disabled.
317func (a *outputApply) configOut(ch chan<- ports.ConfigChanged) chan<- ports.ConfigChanged {
318 if !a.publish {
319 return nil
320 }
321 return ch
322}
323
324// configSent records that core received the latest configuration.
325func (a *outputApply) configSent() { a.publish = false }
326
327// replyOut returns the reply channel and the oldest reply, or a nil channel.
328func (a *outputApply) replyOut(ch chan<- ports.OutputApplied) (chan<- ports.OutputApplied, ports.OutputApplied) {
329 if len(a.replies) == 0 {
330 return nil, ports.OutputApplied{}
331 }
332 return ch, a.replies[0]
333}
334
335// headsOut returns the Wayland inventory channel and the latest inventory,
336// or a nil channel when Wayland has it.
337func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.OutputHeads, ports.OutputHeads) {
338 if !a.headsDue {
339 return nil, ports.OutputHeads{}
340 }
341 return ch, a.state.heads
342}
343
344// headsSent records that Wayland received the latest inventory.
345func (a *outputApply) headsSent() { a.headsDue = false }
346
347// replySent drops the delivered reply.
348func (a *outputApply) replySent() {
349 a.replies[0] = ports.OutputApplied{}
350 a.replies = a.replies[1:]
351}