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 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}

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 26

Tags 10

↑↓ moveenter openesc close