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. The view (NeferGUI owner loop)
22// owns the fields below "owned"; other goroutines only use send.
23type fadeModel struct {
24 cmds chan fadeCmd
25 wake chan struct{}
26 animating atomic.Bool
27 // finish ends the run once a reveal completed; set by the runner before
28 // the loop starts.
29 finish context.CancelFunc
30
31 // owned by the view:
32 started bool
33 from, to float64
34 start time.Time
35 dur time.Duration
36}
37
38func newFadeModel() *fadeModel {
39 return &fadeModel{cmds: make(chan fadeCmd, 4), wake: make(chan struct{}, 1)}
40}
41
42// send queues a retarget (dropping the oldest when full) and requests a redraw.
43func (m *fadeModel) send(c fadeCmd) {
44 for {
45 select {
46 case m.cmds <- c:
47 m.poke()
48 return
49 default:
50 select {
51 case <-m.cmds:
52 default:
53 }
54 }
55 }
56}
57
58func (m *fadeModel) poke() {
59 select {
60 case m.wake <- struct{}{}:
61 default:
62 }
63}
64
65// fadeAlpha is the overlay opacity at now and whether the fade has arrived.
66func fadeAlpha(from, to float64, start time.Time, dur time.Duration, now time.Time) (float64, bool) {
67 if dur <= 0 || !now.Before(start.Add(dur)) {
68 return to, true
69 }
70 t := float64(now.Sub(start)) / float64(dur)
71 return from + (to-from)*t, false
72}
73
74// fadeCSS is the full-screen black layer at opacity a.
75func fadeCSS(a float64) string {
76 return fmt.Sprintf("width:100%%;height:100%%;background-color:rgb(0 0 0 / %.1f%%)", a*100)
77}
78
79// fadeView applies queued retargets, then draws the overlay for this instant.
80func fadeView(f *nefergui.Frame, m *fadeModel) {
81 now := time.Now()
82 for {
83 select {
84 case c := <-m.cmds:
85 m.from, _ = fadeAlpha(m.from, m.to, m.start, m.dur, now)
86 m.to = 0
87 if c.black {
88 m.to = 1
89 }
90 m.start, m.dur, m.started = now, c.dur, true
91 continue
92 default:
93 }
94 break
95 }
96 a, done := fadeAlpha(m.from, m.to, m.start, m.dur, now)
97 m.animating.Store(!done)
98 f.Root(nefergui.Inline(fadeCSS(a)))
99 if done && m.started && m.to == 0 && m.finish != nil {
100 m.finish()
101 }
102}
103
104// fadeRunner runs the overlay until ctx ends or a reveal completes.
105type fadeRunner interface {
106 RunFade(ctx context.Context, m *fadeModel) error
107}
108
109// guiFader renders the overlay as a click-through layer-shell surface.
110type guiFader struct{}
111
112func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {
113 ctx, cancel := context.WithCancel(ctx)
114 defer cancel()
115 m.finish = cancel
116 go func() {
117 t := time.NewTicker(fadeTick)
118 defer t.Stop()
119 for {
120 select {
121 case <-ctx.Done():
122 return
123 case <-t.C:
124 if m.animating.Load() {
125 m.poke()
126 }
127 }
128 }
129 }()
130 err := nefergui.Run(ctx, m, fadeView,
131 nefergui.Title("neferafk-fade"),
132 nefergui.Size(64, 64),
133 nefergui.Transparent(),
134 nefergui.Wake(m.wake),
135 nefergui.Layer(fadeLayer()),
136 )
137 if ctx.Err() != nil && err == context.Canceled {
138 return nil
139 }
140 return err
141}
142
143// fadeLayer: overlay on every output, all anchors, no keyboard, fully
144// click-through.
145func fadeLayer() nefergui.LayerConfig {
146 return nefergui.LayerConfig{
147 AllOutputs: true,
148 Namespace: "neferafk-fade",
149 Level: nefergui.LayerOverlay,
150 Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,
151 Keyboard: nefergui.KeyboardNone,
152 ExclusiveZone: -1,
153 InputRects: []nefergui.Rect{},
154 }
155}