1package visual
2
3import (
4 "context"
5 "fmt"
6 "sync/atomic"
7 "time"
8
9 "github.com/bnema/nefergui"
10)
11
12// fadeTick is the redraw cadence while the fade animates.
13const fadeTick = 16 * time.Millisecond
14
15// fadeCmd retargets the fade: black fades to opaque, otherwise back to clear.
16type fadeCmd struct {
17 black bool
18 dur time.Duration
19}
20
21// fadeModel is the overlay's animation state, shared by the surfaces of every
22// output. The view (NeferGUI owner loop) owns the fields below "owned"; other
23// goroutines only use send and animatingAt.
24type fadeModel struct {
25 cmds chan fadeCmd
26 wake chan struct{}
27 // base is the monotonic origin for tickUntil; fixed at construction.
28 base time.Time
29 // tickUntil keeps redraws coming past the fade end, as nanoseconds since
30 // base (monotonic, like fadeAlpha). It must not depend on which surface
31 // drew last: a wake redraws all surfaces, but each draws at its own instant.
32 tickUntil atomic.Int64
33 // finish ends the run once a reveal completed; set by the runner before
34 // the loop starts.
35 finish context.CancelFunc
36
37 // owned by the view:
38 started bool
39 from, to float64
40 start time.Time
41 dur time.Duration
42}
43
44func newFadeModel() *fadeModel {
45 return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1), base: time.Now()}
46}
47
48// send queues a retarget (dropping the oldest when full) and requests a redraw.
49func (m *fadeModel) send(c fadeCmd) {
50 for {
51 select {
52 case m.cmds <- c:
53 m.poke()
54 return
55 default:
56 select {
57 case <-m.cmds:
58 default:
59 }
60 }
61 }
62}
63
64func (m *fadeModel) poke() {
65 select {
66 case m.wake <- struct{}{}:
67 default:
68 }
69}
70
71// fadeAlpha is the overlay opacity at now and whether the fade has arrived.
72func fadeAlpha(from, to float64, start time.Time, dur time.Duration, now time.Time) (float64, bool) {
73 if dur <= 0 || !now.Before(start.Add(dur)) {
74 return to, true
75 }
76 t := float64(now.Sub(start)) / float64(dur)
77 return from + (to-from)*t, false
78}
79
80// fadeCSS is the full-screen black layer at opacity a.
81func fadeCSS(a float64) string {
82 return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100)
83}
84
85// animatingAt reports whether the ticker must still request redraws.
86func (m *fadeModel) animatingAt(now time.Time) bool {
87 return int64(now.Sub(m.base)) < m.tickUntil.Load()
88}
89
90// fadeView applies queued retargets, then draws the overlay for this instant.
91func fadeView(f *nefergui.Frame, m *fadeModel) {
92 a, done := m.step(time.Now())
93 f.Root(nefergui.Inline(fadeCSS(a)))
94 if done && m.started && m.to == 0 && m.finish != nil {
95 m.finish()
96 }
97}
98
99// step applies queued retargets and returns the opacity at now.
100func (m *fadeModel) step(now time.Time) (float64, bool) {
101 for {
102 select {
103 case c := <-m.cmds:
104 m.from, _ = fadeAlpha(m.from, m.to, m.start, m.dur, now)
105 m.to = 0
106 if c.black {
107 m.to = 1
108 }
109 m.start, m.dur, m.started = now, c.dur, true
110 // Any wake at or after the end marks every surface dirty until it
111 // draws, so that draw shows the final opacity. The extra ticks are
112 // margin for ticker jitter.
113 m.tickUntil.Store(int64(now.Add(c.dur + 2*fadeTick).Sub(m.base)))
114 continue
115 default:
116 }
117 break
118 }
119 return fadeAlpha(m.from, m.to, m.start, m.dur, now)
120}
121
122// fadeRunner runs the overlay until ctx ends or a reveal completes.
123type fadeRunner interface {
124 RunFade(ctx context.Context, m *fadeModel) error
125}
126
127// guiFader renders the overlay as a click-through layer-shell surface.
128type guiFader struct{}
129
130func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {
131 ctx, cancel := context.WithCancel(ctx)
132 defer cancel()
133 m.finish = cancel
134 go func() {
135 t := time.NewTicker(fadeTick)
136 defer t.Stop()
137 was := false
138 for {
139 select {
140 case <-ctx.Done():
141 return
142 case now := <-t.C:
143 // One more poke when animation stops: even a starved ticker
144 // then wakes every surface at least once past the end.
145 animating := m.animatingAt(now)
146 if animating || was {
147 m.poke()
148 }
149 was = animating
150 }
151 }
152 }()
153 err := nefergui.Run(ctx, m, fadeView,
154 nefergui.Title("neferafk-fade"),
155 nefergui.Size(64, 64),
156 nefergui.Transparent(),
157 nefergui.Wake(m.wake),
158 nefergui.Layer(fadeLayer()),
159 )
160 if ctx.Err() != nil && err == context.Canceled {
161 return nil
162 }
163 return err
164}
165
166// fadeLayer: overlay on every output, all anchors, no keyboard, fully
167// click-through.
168func fadeLayer() nefergui.LayerConfig {
169 return nefergui.LayerConfig{
170 AllOutputs: true,
171 Namespace: "neferafk-fade",
172 Level: nefergui.LayerOverlay,
173 Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,
174 Keyboard: nefergui.KeyboardNone,
175 ExclusiveZone: -1,
176 InputRects: []nefergui.Rect{},
177 }
178}