1package visual
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync/atomic"
8 "time"
9
10 "github.com/bnema/neferclient"
11 "github.com/bnema/nefergui"
12 "github.com/bnema/zerowrap"
13)
14
15// fadeTick is the redraw cadence while the fade animates.
16const fadeTick = 16 * time.Millisecond
17
18// fadeCmd retargets the fade: black fades to opaque, otherwise back to clear.
19type fadeCmd struct {
20 black bool
21 dur time.Duration
22}
23
24// fadeModel is the overlay's animation state, shared by the surfaces of every
25// output. The RunFade owner goroutine (through fadeView) owns the fields below
26// "owned"; other goroutines only use send and animatingAt.
27type fadeModel struct {
28 cmds chan fadeCmd
29 wake chan struct{}
30 // base is the monotonic origin for tickUntil; fixed at construction.
31 base time.Time
32 // tickUntil keeps redraws coming past the fade end, as nanoseconds since
33 // base (monotonic, like fadeAlpha). It must not depend on which surface
34 // drew last: a wake redraws all surfaces, but each draws at its own instant.
35 tickUntil atomic.Int64
36 // finish ends the run once a reveal completed; set by the runner before
37 // the loop starts.
38 finish context.CancelFunc
39
40 // owned by the RunFade goroutine:
41 started bool
42 from, to float64
43 start time.Time
44 dur time.Duration
45}
46
47func newFadeModel() *fadeModel {
48 return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1), base: time.Now()}
49}
50
51// send queues a retarget (dropping the oldest when full) and requests a redraw.
52func (m *fadeModel) send(c fadeCmd) {
53 for {
54 select {
55 case m.cmds <- c:
56 m.poke()
57 return
58 default:
59 select {
60 case <-m.cmds:
61 default:
62 }
63 }
64 }
65}
66
67func (m *fadeModel) poke() {
68 select {
69 case m.wake <- struct{}{}:
70 default:
71 }
72}
73
74// fadeAlpha is the overlay opacity at now and whether the fade has arrived.
75func fadeAlpha(from, to float64, start time.Time, dur time.Duration, now time.Time) (float64, bool) {
76 if dur <= 0 || !now.Before(start.Add(dur)) {
77 return to, true
78 }
79 t := float64(now.Sub(start)) / float64(dur)
80 return from + (to-from)*t, false
81}
82
83// fadeCSS is the full-screen black layer at opacity a.
84func fadeCSS(a float64) string {
85 return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100)
86}
87
88// animatingAt reports whether the ticker must still request redraws.
89func (m *fadeModel) animatingAt(now time.Time) bool {
90 return int64(now.Sub(m.base)) < m.tickUntil.Load()
91}
92
93// fadeView applies queued retargets, then draws the overlay for this instant.
94func fadeView(f *nefergui.Frame, m *fadeModel) {
95 a, done := m.step(time.Now())
96 f.Root(nefergui.Inline(fadeCSS(a)))
97 if done && m.started && m.to == 0 && m.finish != nil {
98 m.finish()
99 }
100}
101
102// step applies queued retargets and returns the opacity at now.
103func (m *fadeModel) step(now time.Time) (float64, bool) {
104 for {
105 select {
106 case c := <-m.cmds:
107 m.from, _ = fadeAlpha(m.from, m.to, m.start, m.dur, now)
108 m.to = 0
109 if c.black {
110 m.to = 1
111 }
112 m.start, m.dur, m.started = now, c.dur, true
113 // Any wake at or after the end marks every surface dirty until it
114 // draws, so that draw shows the final opacity. The extra ticks are
115 // margin for ticker jitter.
116 m.tickUntil.Store(int64(now.Add(c.dur + 2*fadeTick).Sub(m.base)))
117 continue
118 default:
119 }
120 break
121 }
122 return fadeAlpha(m.from, m.to, m.start, m.dur, now)
123}
124
125// fadeRunner runs the overlay until ctx ends or a reveal completes.
126type fadeRunner interface {
127 RunFade(ctx context.Context, m *fadeModel) error
128}
129
130// guiFader renders the overlay as one click-through layer-shell surface per
131// output, on its own Wayland connection.
132type guiFader struct{ log zerowrap.Logger }
133
134func (g guiFader) RunFade(ctx context.Context, m *fadeModel) (err error) {
135 ctx, cancel := context.WithCancel(ctx)
136 defer cancel()
137 m.finish = cancel
138 conn, err := neferclient.Connect(ctx, "")
139 if err != nil {
140 if ctx.Err() != nil {
141 return nil
142 }
143 return fmt.Errorf("connect: %w", err)
144 }
145 f := &fader{m: m}
146 f.screens = newScreens(conn)
147 // The overlay takes no input: an event failure only leaves the fade less
148 // smooth, so it is logged and the fade goes on.
149 f.screens.onError = func(err error) error {
150 g.log.Warn().Err(err).Msg("fade event failed")
151 return nil
152 }
153 defer func() { err = errors.Join(err, conn.Close(), f.closeAll()) }()
154 for _, o := range conn.Outputs() {
155 if err = f.cover(o); err != nil {
156 return err
157 }
158 }
159 go fadeTicker(ctx, m)
160 var retry <-chan time.Time
161 for {
162 select {
163 case <-ctx.Done():
164 return nil // cancelled, or the reveal finished
165 case <-conn.Wake():
166 if err = conn.Dispatch(f); err != nil {
167 return fmt.Errorf("dispatch: %w", err)
168 }
169 case <-m.wake:
170 f.invalidateAll()
171 case <-retry:
172 }
173 if f.err != nil {
174 return f.err
175 }
176 pending, err := f.drawAll()
177 if err != nil {
178 return err
179 }
180 retry = nil
181 if pending {
182 retry = time.After(gpuRetry)
183 }
184 }
185}
186
187type fader struct {
188 *screens
189 m *fadeModel
190 unnamed bool // a surface without an output name exists
191}
192
193// cover adds the overlay surface of an output.
194func (f *fader) cover(o neferclient.Output) error {
195 if o.Name == "" { // wl_output older than v4: the compositor picks the output
196 if f.unnamed {
197 return nil // a second unnamed surface would land on the same output
198 }
199 f.unnamed = true
200 }
201 surf, err := f.conn.NewLayerSurface(fadeLayer(o.Name))
202 if err != nil {
203 return fmt.Errorf("fade surface for output %s: %w", o.Name, err)
204 }
205 s := newScreen(f.conn, surf, o, true)
206 s.view = func(fr *nefergui.Frame) { fadeView(fr, f.m) }
207 f.add(s)
208 return nil
209}
210
211// OutputAdded covers an output that appeared during the fade.
212func (f *fader) OutputAdded(o *neferclient.Output) {
213 if !f.covers(o.Global) {
214 if err := f.cover(*o); err != nil {
215 f.fail(err)
216 }
217 }
218}
219
220// OutputRemoved frees the overlay of an output that went away.
221func (f *fader) OutputRemoved(global uint32) {
222 for _, s := range f.byID {
223 if s.output == global && s.name == "" {
224 f.unnamed = false
225 }
226 }
227 f.screens.OutputRemoved(global)
228}
229
230// Closed: the compositor closed an overlay surface. An output left without
231// one stays unfaded; the compositor chose to close it.
232func (f *fader) Closed(id neferclient.SurfaceID) {
233 if s := f.byID[id]; s != nil {
234 if s.name == "" {
235 f.unnamed = false
236 }
237 if err := f.forget(s); err != nil {
238 f.fail(err)
239 }
240 }
241}
242
243// fadeTicker requests redraws while the fade animates.
244func fadeTicker(ctx context.Context, m *fadeModel) {
245 t := time.NewTicker(fadeTick)
246 defer t.Stop()
247 was := false
248 for {
249 select {
250 case <-ctx.Done():
251 return
252 case now := <-t.C:
253 // One more poke when animation stops: even a starved ticker then
254 // wakes every surface at least once past the end.
255 animating := m.animatingAt(now)
256 if animating || was {
257 m.poke()
258 }
259 was = animating
260 }
261 }
262}
263
264// fadeLayer: overlay on one output, all anchors, no keyboard, fully
265// click-through. An empty output name lets the compositor choose.
266func fadeLayer(output string) neferclient.LayerConfig {
267 return neferclient.LayerConfig{
268 Output: output,
269 Namespace: "neferafk-fade",
270 Level: neferclient.LayerOverlay,
271 Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
272 Keyboard: neferclient.KeyboardNone,
273 ExclusiveZone: -1,
274 InputRects: []neferclient.Rect{},
275 }
276}