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 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)) {
119 return fmt.Errorf("headless mode and enabled state are fixed")
120 }
121 if change.Mode != nil && !slices.ContainsFunc(current.Modes, func(m ports.OutputMode) bool {
122 return m.Width == change.Mode.Width && m.Height == change.Mode.Height && m.RefreshMilli == change.Mode.RefreshMilli
123 }) {
124 return fmt.Errorf("unknown mode on %s", change.Name)
125 }
126 }
127 return nil
128}
129func (o *outputOverrides) apply(req ports.OutputApply) (ports.Config, error) {
130 if err := o.validate(req); err != nil {
131 return ports.Config{}, err
132 }
133 if req.Test {
134 return o.effective(), nil
135 }
136 for _, h := range req.Heads {
137 entry, ok := o.overrides[h.Name]
138 if !ok {
139 index := slices.IndexFunc(o.file.Outputs, func(v ports.OutputConfig) bool { return v.Name == h.Name })
140 if index >= 0 {
141 entry = o.file.Outputs[index]
142 } else {
143 entry.Name = h.Name
144 }
145 }
146 entry.Off = !h.Enabled
147 if h.Scale > 0 {
148 entry.Scale = h.Scale
149 }
150 if h.Pos != nil {
151 entry.Pos = &image.Point{X: h.Pos.X, Y: h.Pos.Y}
152 }
153 if h.Mode != nil {
154 entry.Mode = fmt.Sprintf("%dx%d@%.3f", h.Mode.Width, h.Mode.Height, float64(h.Mode.RefreshMilli)/1000)
155 }
156 o.overrides[h.Name] = entry
157 }
158 return o.effective(), nil
159}
160
161// errApplySuperseded answers a request whose configuration a reload replaced.
162var errApplySuperseded = errors.New("output apply superseded by reload")
163
164// outputApply owns output management from request to reply: runtime
165// overrides, supersede by reload, and rollback after a failed backend apply.
166// It belongs to the backend owner loop, which starts the configuration next
167// returns, reports each result through finished and drains the outbox (core
168// configuration, Wayland replies and inventory) with sends that never block
169// the loop.
170type outputApply struct {
171 state *outputOverrides
172 log zerowrap.Logger
173 // running is set while the backend applies a configuration. The next
174 // one waits in start until it finishes: configurations never overlap.
175 running bool
176 start ports.Config
177 pending bool
178 // req is the request behind the backend work; nil for a reload or after
179 // a reload superseded it.
180 req *ports.OutputApply
181 previous map[string]ports.OutputConfig
182 rollback bool
183 failure error
184 // config is the latest effective configuration core has not received.
185 config ports.ConfigChanged
186 publish bool
187 replies []ports.OutputApplied
188 // headsDue is set while Wayland has not received the latest inventory.
189 headsDue bool
190}
191
192func newOutputApply(state *outputOverrides, log zerowrap.Logger) *outputApply {
193 return &outputApply{state: state, log: log}
194}
195
196// reload replaces the file configuration and supersedes the pending request.
197// Only the latest reload needs backend work.
198func (a *outputApply) reload(file ports.Config) {
199 cfg := a.state.reload(file)
200 a.setConfig(cfg)
201 if a.req != nil {
202 a.reply(a.req.ID, errApplySuperseded)
203 a.req = nil
204 }
205 if !a.state.headless {
206 a.due(cfg)
207 }
208}
209
210// heads records the backend inventory: it validates requests and waits for
211// Wayland in the outbox, latest only.
212func (a *outputApply) heads(heads ports.OutputHeads) {
213 a.state.heads = heads
214 a.headsDue = true
215}
216
217// request validates a protocol request. Test, invalid and headless requests
218// are answered at once; others wait for the backend.
219func (a *outputApply) request(req ports.OutputApply) {
220 if a.running || a.pending {
221 a.reply(req.ID, errors.New("output apply already pending"))
222 return
223 }
224 previous := make(map[string]ports.OutputConfig, len(a.state.overrides))
225 maps.Copy(previous, a.state.overrides)
226 cfg, err := a.state.apply(req)
227 if err != nil || req.Test {
228 a.reply(req.ID, err)
229 return
230 }
231 a.setConfig(cfg)
232 if a.state.headless {
233 a.reply(req.ID, nil)
234 return
235 }
236 a.req, a.previous, a.rollback, a.failure = &req, previous, false, nil
237 a.due(cfg)
238}
239
240// next returns the configuration the backend must start now, once. It
241// returns nothing while the backend applies the previous one.
242func (a *outputApply) next() (ports.Config, bool) {
243 if a.running || !a.pending {
244 return ports.Config{}, false
245 }
246 cfg := a.start
247 a.start, a.pending, a.running = ports.Config{}, false, true
248 return cfg, true
249}
250
251// finished records the backend result of the running configuration. When a
252// reload is waiting, the result belongs to a superseded configuration and is
253// dropped. After a failed request the previous overrides return and the
254// rollback configuration becomes due; the request is answered with the
255// original failure once the rollback is done.
256func (a *outputApply) finished(err error) {
257 if !a.running {
258 return
259 }
260 a.running = false
261 if a.pending {
262 return
263 }
264 req := a.req
265 if req == nil {
266 return
267 }
268 if a.rollback {
269 a.req = nil
270 a.reply(req.ID, a.failure)
271 return
272 }
273 if err != nil {
274 a.state.overrides = a.previous
275 cfg := a.state.effective()
276 a.setConfig(cfg)
277 a.rollback, a.failure = true, err
278 a.due(cfg)
279 return
280 }
281 a.req = nil
282 for _, h := range req.Heads {
283 a.log.Info().Str("output", h.Name).Bool("enabled", h.Enabled).Float64("scale", h.Scale).Msg("output configuration applied")
284 }
285 a.reply(req.ID, nil)
286}
287
288// due replaces the configuration waiting to start: only the latest counts.
289func (a *outputApply) due(cfg ports.Config) {
290 a.start, a.pending = cfg, true
291}
292
293func (a *outputApply) setConfig(cfg ports.Config) {
294 a.config = ports.ConfigChanged{Config: cfg}
295 a.publish = true
296}
297
298func (a *outputApply) reply(id uint64, err error) {
299 if err != nil {
300 a.log.Warn().Err(err).Msg("output configuration failed")
301 }
302 a.replies = append(a.replies, ports.OutputApplied{ID: id, Err: err})
303}
304
305// configOut returns the core channel when a configuration waits, else nil so
306// the owner loop's send case stays disabled.
307func (a *outputApply) configOut(ch chan<- ports.ConfigChanged) chan<- ports.ConfigChanged {
308 if !a.publish {
309 return nil
310 }
311 return ch
312}
313
314// configSent records that core received the latest configuration.
315func (a *outputApply) configSent() { a.publish = false }
316
317// replyOut returns the reply channel and the oldest reply, or a nil channel.
318func (a *outputApply) replyOut(ch chan<- ports.OutputApplied) (chan<- ports.OutputApplied, ports.OutputApplied) {
319 if len(a.replies) == 0 {
320 return nil, ports.OutputApplied{}
321 }
322 return ch, a.replies[0]
323}
324
325// headsOut returns the Wayland inventory channel and the latest inventory,
326// or a nil channel when Wayland has it.
327func (a *outputApply) headsOut(ch chan<- ports.OutputHeads) (chan<- ports.OutputHeads, ports.OutputHeads) {
328 if !a.headsDue {
329 return nil, ports.OutputHeads{}
330 }
331 return ch, a.state.heads
332}
333
334// headsSent records that Wayland received the latest inventory.
335func (a *outputApply) headsSent() { a.headsDue = false }
336
337// replySent drops the delivered reply.
338func (a *outputApply) replySent() {
339 a.replies[0] = ports.OutputApplied{}
340 a.replies = a.replies[1:]
341}