outputmanagement.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close