v0.3.2 a514042

fade.go RawHistoryFolder

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}

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 2

Tags 9

↑↓ moveenter openesc close