v0.2.0 c616855

bar.go RawHistoryFolder

1// Package bar connects the Wayland layer surface, the Vulkan renderer and the
2// module layout.
3//
4// Everything runs on one goroutine: the loop in Run waits for a Wayland
5// wake-up or a module frame, calls Dispatch, and every Handler method,
6// renderer call and Present happens inside it. Modules only publish frames
7// through a channel.
8package bar
9
10import (
11	"context"
12	"errors"
13	"fmt"
14	"log/slog"
15	"math"
16	"os"
17	"path/filepath"
18	"slices"
19	"strings"
20	"syscall"
21	"time"
22
23	"github.com/bnema/neferclient"
24
25	"github.com/bnema/neferbar/internal/config"
26	"github.com/bnema/neferbar/internal/fswatch"
27	"github.com/bnema/neferbar/internal/glyph"
28	"github.com/bnema/neferbar/internal/gpu"
29	"github.com/bnema/neferbar/internal/layout"
30	"github.com/bnema/neferbar/internal/module"
31	"github.com/bnema/neferbar/internal/syncobj"
32	"github.com/bnema/neferbar/internal/theme"
33)
34
35const acquireTimelineID = 1 << 32
36
37// Stats counts what the bar did; read it only from the loop goroutine or after
38// Run returns.
39type Stats struct {
40	Presented  uint64 // frames handed to the compositor
41	Rebuilds   uint64 // renderer rebuilds (scale or size changes)
42	Recreated  uint64 // layer surface recreations
43	NoSlot     uint64 // draws skipped because every image was held
44	Dirty      uint64 // module wake-ups that changed the row
45	ModuleWake uint64
46}
47
48// Bar is the running application.
49type Bar struct {
50	cfg     config.Config
51	log     *slog.Logger
52	display string
53	fg, bg  [3]uint8
54
55	conn *neferclient.Conn
56	surf *neferclient.Surface
57	sid  neferclient.SurfaceID
58
59	configured bool
60	canPresent bool
61	dirty      bool
62
63	// font
64	fonts  glyph.Paths
65	face   *glyph.Face
66	facePx float64
67
68	dev  *gpu.Device
69	node *syncobj.Node
70	fb   *neferclient.Feedback
71	rend *gpu.Renderer
72	mod  gpu.Modifier
73
74	haveAcquire bool
75	frame       gpu.Frame
76	watched     [gpu.SlotCount]int // watched eventfds, -1 when none
77
78	runners   []runner
79	mods      []*module.Module // the modules of runners, in config order
80	runCtx    context.Context
81	cancelRun context.CancelFunc
82	lay       *layout.Layout
83	modCh     chan struct{}
84	cfgPath   string
85	cfgCh     chan config.Config
86
87	// scriptCh carries the directory of a script that changed on disk.
88	scriptCh     chan string
89	scriptCancel context.CancelFunc
90	stale        map[string]bool // directories whose modules restart at the next syncModules
91
92	theme       theme.Theme
93	themeCh     chan struct{}
94	themeCancel context.CancelFunc
95	pal         [16][3]uint8
96	env         []string // theme colors for the scripts
97
98	// settling is true between a scale or size change and the moment both the
99	// new scale and the new logical width have arrived. They come as separate
100	// events, in either order; building a renderer from half of them presents a
101	// buffer of the wrong size, and some compositors keep that size.
102	settling bool
103	settleC  <-chan time.Time
104
105	// fresh is true from the moment a layer surface is created until a renderer
106	// exists for it. A new surface reports scale 1 until the compositor says
107	// otherwise, so its first geometry is not trusted until the events settle.
108	fresh bool
109	// asked is the last (height, scale) a surface was recreated for. Asking for
110	// the same pair twice means the compositor will not give that height, and
111	// the bar takes what it gets instead of looping.
112	askedH     int32
113	askedScale float64
114
115	// mappedW is the logical width the current layer surface was first drawn at.
116	// It resets to 0 when the surface is recreated.
117	mappedW int32
118	Stats   Stats
119	fatal   error
120	closed  bool
121}
122
123// New builds a bar from a validated config. cfgPath is the file the config came
124// from: relative theme paths are read from its directory, and the bar reloads
125// it when it changes (empty: no reload). display names the Wayland socket;
126// empty uses $WAYLAND_DISPLAY.
127func New(cfg config.Config, cfgPath string, log *slog.Logger, display string) (*Bar, error) {
128	b := &Bar{cfg: cfg, cfgPath: cfgPath, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),
129		themeCh: make(chan struct{}, 1), scriptCh: make(chan string, 16)}
130	var err error
131	if err = b.loadTheme(); err != nil {
132		return nil, err
133	}
134	if b.fonts, err = findFonts(cfg.Bar.Font, log); err != nil {
135		return nil, err
136	}
137	for i := range b.watched {
138		b.watched[i] = -1
139	}
140	return b, nil
141}
142
143// barColors picks the bar's own colors: the settings win, otherwise the theme
144// foreground, and a background one step toward the foreground so the bar
145// stands out from a terminal window of the same theme.
146func barColors(cfg config.Config, th theme.Theme) (fg, bg [3]uint8) {
147	fg, bg = th.Foreground, theme.Mix(th.Background, th.Foreground, 0.07)
148	if c, err := config.ParseColor(cfg.Bar.Foreground); err == nil && cfg.Bar.Foreground != "" {
149		fg = c
150	}
151	if c, err := config.ParseColor(cfg.Bar.Background); err == nil && cfg.Bar.Background != "" {
152		bg = c
153	}
154	return fg, bg
155}
156
157// loadTheme resolves bar.theme and applies it. When the theme cannot be read
158// the built-in colors are used, and the problem is logged.
159func (b *Bar) loadTheme() error {
160	base := filepath.Dir(b.cfgPath)
161	if b.cfgPath == "" {
162		base = "."
163	}
164	th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
165	if err != nil {
166		b.log.Warn("using the built-in colors: "+err.Error(), "theme", b.cfg.Bar.Theme)
167	} else {
168		b.log.Info("theme", "source", th.Source)
169	}
170	b.setTheme(th)
171	return nil
172}
173
174// setTheme makes th the colors of the bar and of its scripts.
175func (b *Bar) setTheme(th theme.Theme) {
176	b.theme = th
177	b.fg, b.bg = barColors(b.cfg, th)
178	b.pal = th.Palette
179	b.env = th.EnvVars(b.bg, b.fg, b.cfg.Bar.Accent)
180	if b.lay != nil {
181		b.lay.SetColors(b.fg, b.bg, b.pal)
182	}
183	b.dirty = true
184	b.watchTheme()
185}
186
187// watchTheme watches every file the theme was read from, so changing the
188// terminal's theme changes the bar.
189func (b *Bar) watchTheme() {
190	if b.themeCancel != nil {
191		b.themeCancel()
192	}
193	if b.runCtx == nil {
194		return // Run has not started; it calls this again
195	}
196	ctx, cancel := context.WithCancel(b.runCtx)
197	b.themeCancel = cancel
198	for _, f := range b.theme.Files {
199		go func() {
200			if err := fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh); err != nil {
201				b.log.Warn("cannot watch a theme file; edits to it need a restart", "file", f, "err", err)
202			}
203		}()
204	}
205}
206
207// watchScripts watches the directory of every module's script, so editing a
208// script, or a helper it sources from the same directory, restarts the modules
209// that live there. It runs again whenever the set of modules changes.
210func (b *Bar) watchScripts() {
211	if b.scriptCancel != nil {
212		b.scriptCancel()
213	}
214	if b.runCtx == nil {
215		return // Run has not started; it calls syncModules, which calls this
216	}
217	ctx, cancel := context.WithCancel(b.runCtx)
218	b.scriptCancel = cancel
219	seen := map[string]bool{}
220	for _, r := range b.runners {
221		dir := module.ScriptDir(r.cfg.Exec)
222		if dir == "" || seen[dir] {
223			continue
224		}
225		seen[dir] = true
226		go func() {
227			changed := make(chan struct{}, 1)
228			go func() {
229				if err := fswatch.WatchDir(ctx, dir, 150*time.Millisecond, changed); err != nil {
230					b.log.Warn("cannot watch a script directory; edits to its scripts need a restart", "dir", dir, "err", err)
231				}
232			}()
233			for {
234				select {
235				case <-ctx.Done():
236					return
237				case <-changed:
238					select {
239					case b.scriptCh <- dir:
240					case <-ctx.Done():
241						return
242					}
243				}
244			}
245		}()
246	}
247}
248
249// restartScripts restarts the modules whose script is in dir.
250func (b *Bar) restartScripts(dir string) {
251	b.log.Info("script changed; restarting its modules", "dir", dir)
252	if b.stale == nil {
253		b.stale = map[string]bool{}
254	}
255	b.stale[dir] = true
256	b.syncModules(b.cfg.Module)
257}
258
259// reloadTheme re-reads the theme after one of its files changed.
260func (b *Bar) reloadTheme() {
261	base := filepath.Dir(b.cfgPath)
262	if b.cfgPath == "" {
263		base = "."
264	}
265	th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
266	if err != nil {
267		b.log.Warn("theme reload failed; keeping the current colors", "err", err)
268		return
269	}
270	if th.Equal(b.theme) {
271		// Same colors, but the files that make them up may have changed.
272		if !slices.Equal(th.Files, b.theme.Files) {
273			b.theme.Files = th.Files
274			b.watchTheme()
275		}
276		return
277	}
278	b.log.Info("theme changed", "source", th.Source)
279	b.setTheme(th)
280	b.syncModules(b.cfg.Module) // scripts get the new colors in their environment
281}
282
283// runner is one started module and how to stop it.
284type runner struct {
285	cfg    config.Module
286	env    []string
287	m      *module.Module
288	cancel context.CancelFunc
289}
290
291var zones = map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
292
293// syncModules makes the running modules match want. A module with the same
294// name, zone and command keeps running; every other one is stopped, and a new
295// or changed one is started.
296func (b *Bar) syncModules(want []config.Module) {
297	old := make(map[string]runner, len(b.runners))
298	for _, r := range b.runners {
299		old[r.cfg.Name] = r
300	}
301	next := make([]runner, 0, len(want))
302	for _, w := range want {
303		if r, ok := old[w.Name]; ok {
304			delete(old, w.Name)
305			if r.cfg == w && slices.Equal(r.env, b.env) && !b.stale[module.ScriptDir(w.Exec)] {
306				next = append(next, r)
307				continue
308			}
309			r.cancel()
310		}
311		ctx, cancel := context.WithCancel(b.runCtx)
312		m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
313		m.Env = b.env
314		m.Start(ctx)
315		next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
316	}
317	for _, r := range old {
318		r.cancel()
319	}
320	b.runners = next
321	clear(b.stale)
322	b.watchScripts()
323	b.mods = b.mods[:0]
324	for _, r := range next {
325		b.mods = append(b.mods, r.m)
326	}
327	if b.lay != nil {
328		b.lay.SetModules(b.mods)
329	}
330}
331
332// applyConfig switches to next. Nothing changes if it cannot be applied.
333func (b *Bar) applyConfig(next config.Config) {
334	old := b.cfg
335	var err error
336	fontChanged := next.Bar.Font != old.Bar.Font
337	fonts := b.fonts
338	if fontChanged {
339		if fonts, err = findFonts(next.Bar.Font, b.log); err != nil {
340			b.log.Warn("config not applied", "err", err)
341			return
342		}
343	}
344	if next.Bar.Output != old.Bar.Output {
345		b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
346		next.Bar.Output = old.Bar.Output
347	}
348	b.cfg = next
349	b.fonts = fonts
350	if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed
351		b.log.Warn("theme not applied", "err", err)
352	}
353	b.syncModules(next.Module)
354	b.dirty = true
355	if next.Bar.Position != old.Bar.Position && b.surf != nil {
356		// The edge is part of how a layer surface is created: make a new one.
357		b.log.Info("moving the bar", "to", next.Bar.Position)
358		b.dropRenderer()
359		_, h, _ := b.surf.Size()
360		if err = b.surf.Close(); err != nil {
361			b.fail(err)
362			return
363		}
364		if err = b.createSurface(h); err != nil {
365			b.fail(err)
366			return
367		}
368		b.Stats.Recreated++
369	}
370	if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
371		b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
372		b.reconcile()
373	}
374	b.log.Info("config reloaded")
375}
376
377// Run connects to the compositor and serves until ctx ends or the surface is
378// closed. It returns the first fatal error.
379func (b *Bar) Run(ctx context.Context) (err error) {
380	b.conn, err = neferclient.Connect(ctx, b.display)
381	if err != nil {
382		return connectError(b.display, err)
383	}
384	defer func() {
385		err = errors.Join(err, b.shutdown())
386		b.log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,
387			"recreated", b.Stats.Recreated, "no_slot", b.Stats.NoSlot, "dirty", b.Stats.Dirty, "module_wakes", b.Stats.ModuleWake)
388	}()
389	if err = b.createSurface(estimateHeight(b.cfg.Bar.Size * b.cfg.Bar.Scale)); err != nil {
390		return err
391	}
392	runCtx, cancel := context.WithCancel(ctx)
393	defer cancel()
394	b.runCtx, b.cancelRun = runCtx, cancel
395	b.watchTheme()
396	b.syncModules(b.cfg.Module)
397	if b.cfgPath != "" {
398		go func() {
399			if err := config.Watch(runCtx, b.cfgPath, b.cfg, b.cfgCh, b.log); err != nil {
400				b.log.Warn("config reload is off", "err", err)
401			}
402		}()
403	}
404	for !b.closed {
405		select {
406		case <-ctx.Done():
407			return nil
408		case <-b.conn.Wake():
409			if err = b.conn.Dispatch(b); err != nil {
410				return fmt.Errorf("dispatch: %w", err)
411			}
412		case <-b.modCh:
413			b.Stats.ModuleWake++
414		case next := <-b.cfgCh:
415			b.applyConfig(next)
416		case <-b.themeCh:
417			b.reloadTheme()
418		case dir := <-b.scriptCh:
419			b.restartScripts(dir)
420		case <-b.settleC:
421			b.settleC, b.settling = nil, false
422			b.reconcile()
423		}
424		if b.fatal != nil {
425			return b.fatal
426		}
427		if err = b.step(); err != nil {
428			return err
429		}
430	}
431	return nil
432}
433
434// findFonts resolves the four font files of a family and warns when
435// fontconfig had to substitute another family.
436func findFonts(family string, log *slog.Logger) (glyph.Paths, error) {
437	p, err := glyph.FindFonts(family)
438	if err != nil {
439		return p, err
440	}
441	if got := glyph.FamilyOf(family); got != "" && !strings.EqualFold(got, family) && !strings.HasPrefix(strings.ToLower(family), strings.ToLower(got)) {
442		log.Warn("font not found, fontconfig substituted another family: icons may be missing", "wanted", family, "got", got, "file", p[0])
443	}
444	return p, nil
445}
446
447// socketPath mirrors how neferclient resolves the socket, for error messages.
448func socketPath(display string) string {
449	if display == "" {
450		display = os.Getenv("WAYLAND_DISPLAY")
451	}
452	if display == "" {
453		display = "wayland-0"
454	}
455	if filepath.IsAbs(display) {
456		return display
457	}
458	return filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), display)
459}
460
461// connectError turns a failed dial into one actionable sentence.
462func connectError(display string, err error) error {
463	path := socketPath(display)
464	switch {
465	case errors.Is(err, os.ErrNotExist):
466		return fmt.Errorf("no Wayland socket at %s: is the compositor running? Set WAYLAND_DISPLAY, NEFERBAR_DISPLAY or -display", path)
467	case errors.Is(err, syscall.ECONNREFUSED):
468		return fmt.Errorf("the Wayland socket %s exists but refuses connections: stale socket or compositor not ready", path)
469	case errors.Is(err, os.ErrPermission):
470		return fmt.Errorf("not allowed to open the Wayland socket %s", path)
471	}
472	return err
473}
474
475// estimateHeight is the first request, before the real cell height is known.
476func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) }
477
478// edge is the layer-shell anchor for a bar.position value.
479func edge(position string) neferclient.Anchor {
480	if position == "bottom" {
481		return neferclient.AnchorBottom
482	}
483	return neferclient.AnchorTop
484}
485
486func (b *Bar) createSurface(h int32) error {
487	surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{
488		Output:        b.cfg.Bar.Output,
489		Namespace:     "neferbar",
490		Level:         neferclient.LayerTop,
491		Anchors:       edge(b.cfg.Bar.Position) | neferclient.AnchorLeft | neferclient.AnchorRight,
492		ExclusiveZone: h,
493		Height:        h,
494		InputRects:    []neferclient.Rect{}, // read only: click-through
495	})
496	if err != nil {
497		return fmt.Errorf("layer surface: %w", err)
498	}
499	b.surf, b.sid = surf, surf.ID()
500	b.mappedW = 0
501	b.fresh = true
502	b.configured, b.canPresent = false, false
503	return nil
504}
505
506// step reparses changed modules and draws when something is ready.
507func (b *Bar) step() error {
508	if b.lay != nil && b.lay.Update() {
509		b.dirty = true
510		b.Stats.Dirty++
511	}
512	return b.draw()
513}
514
515func (b *Bar) draw() error {
516	if !b.dirty || !b.canPresent || b.rend == nil || b.settling {
517		return nil
518	}
519	ok, err := b.rend.Draw(b.lay.Compose(), b.bg, &b.frame)
520	if err != nil {
521		return fmt.Errorf("draw: %w", err)
522	}
523	if !ok {
524		b.Stats.NoSlot++ // Released retries
525		return nil
526	}
527	if err = b.present(); err != nil {
528		return err
529	}
530	b.dirty, b.canPresent = false, false
531	b.Stats.Presented++
532	return nil
533}
534
535func (b *Bar) present() error {
536	f := &b.frame
537	if f.NewBuffer {
538		img := f.Image
539		buf := neferclient.Buffer{
540			Width: img.Width, Height: img.Height, FourCC: gpu.DRMFormatXRGB8888, Modifier: img.Modifier, PlaneCount: 1,
541		}
542		buf.Planes[0] = neferclient.Plane{FD: img.FD, Offset: img.Offset, Stride: img.Stride}
543		if err := b.surf.ImportBuffer(uint64(f.Slot), &buf); err != nil {
544			return fmt.Errorf("import buffer: %w", err)
545		}
546		if err := b.surf.ImportTimeline(uint64(f.Slot), f.ReleaseTimeline); err != nil {
547			return fmt.Errorf("import release timeline: %w", err)
548		}
549		if err := b.conn.WatchFD(f.ReleaseEventFD, uint64(f.Slot)); err != nil {
550			return fmt.Errorf("watch release fd: %w", err)
551		}
552		b.watched[f.Slot] = f.ReleaseEventFD
553	}
554	if !b.haveAcquire {
555		if err := b.surf.ImportTimeline(acquireTimelineID, b.rend.AcquireTimelineFD()); err != nil {
556			return fmt.Errorf("import acquire timeline: %w", err)
557		}
558		b.haveAcquire = true
559	}
560	err := b.surf.Present(&neferclient.Present{
561		Buffer:          uint64(f.Slot),
562		AcquireTimeline: acquireTimelineID,
563		ReleaseTimeline: uint64(f.Slot),
564		AcquirePoint:    f.AcquirePoint,
565		ReleasePoint:    f.ReleasePoint,
566		Opaque:          true,
567	})
568	if err != nil {
569		return fmt.Errorf("present: %w", err)
570	}
571	return nil
572}
573
574// reconcile brings the font, the layer surface height and the renderer in
575// line with the current scale and feedback. It runs on every event that can
576// change them and does nothing when everything already matches.
577func (b *Bar) reconcile() {
578	if !b.configured || b.fb == nil {
579		return
580	}
581	if b.dev == nil {
582		dev, err := gpu.Open(b.fb.MainDevice)
583		if err != nil {
584			b.fail(err)
585			return
586		}
587		node, err := syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", dev.RenderMinor)))
588		if err != nil {
589			dev.Close()
590			b.fail(err)
591			return
592		}
593		b.dev, b.node = dev, node
594	}
595	w, h, scale := b.surf.Size()
596	px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale
597	if b.face == nil || b.facePx != px {
598		face, err := glyph.Load(b.fonts, px)
599		if err != nil {
600			b.fail(err)
601			return
602		}
603		b.dropRenderer()
604		b.face.Close()
605		b.face, b.facePx = face, px
606	}
607	// The bar is one cell high: logical height = ceil(cell / scale).
608	wantH := int32(math.Ceil(float64(b.face.CellH)/scale - 1e-9))
609	if h != wantH && b.askedH == wantH && b.askedScale == scale {
610		// Already asked for exactly this and got something else: the compositor
611		// clamps the height. Use what we have rather than asking forever.
612		b.log.Warn("the compositor gave another bar height than asked; using it", "asked", wantH, "got", h)
613		wantH = h
614	}
615	if h != wantH {
616		b.askedH, b.askedScale = wantH, scale
617		b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
618		b.dropRenderer()
619		if err := b.surf.Close(); err != nil {
620			b.fail(err)
621			return
622		}
623		if err := b.createSurface(wantH); err != nil {
624			b.fail(err)
625			return
626		}
627		b.Stats.Recreated++
628		return
629	}
630	pw, ph, err := b.surf.PhysicalSize()
631	if err != nil {
632		b.fail(err)
633		return
634	}
635	if b.rend != nil && b.rend.W == pw && b.rend.H == ph {
636		return
637	}
638	if b.mappedW != 0 && w != b.mappedW {
639		// The logical width changed (a scale change on the output). NeferWL
640		// places a layer surface from the size it had when it was mapped and
641		// does not re-read it when only the buffer and viewport change, so the
642		// old width would stay: centered in a narrower output, the left part cut
643		// off. A new surface is mapped fresh and placed from its real size.
644		b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w)
645		b.dropRenderer()
646		if err := b.surf.Close(); err != nil {
647			b.fail(err)
648			return
649		}
650		if err := b.createSurface(h); err != nil {
651			b.fail(err)
652			return
653		}
654		b.Stats.Recreated++
655		return
656	}
657	b.dropRenderer()
658	if mod, err := b.chooseModifier(); err != nil {
659		b.fail(err)
660		return
661	} else {
662		b.mod = mod
663	}
664	rend, err := gpu.NewRenderer(b.dev, b.node, b.mod, b.face, pw, ph)
665	if err != nil {
666		b.fail(err)
667		return
668	}
669	b.rend, b.mappedW = rend, w
670	b.fresh = false
671	b.Stats.Rebuilds++
672	if b.lay == nil {
673		b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.pal, b.mods)
674	} else {
675		b.lay.Resize(rend.Cols())
676	}
677	b.lay.Update()
678	b.dirty = true
679	b.log.Info("renderer ready", "px", pw, "py", ph, "scale", scale, "cell", fmt.Sprintf("%dx%d", b.face.CellW, b.face.CellH), "cols", rend.Cols())
680}
681
682func (b *Bar) chooseModifier() (gpu.Modifier, error) {
683	supported, err := b.dev.ExportableModifiers()
684	if err != nil {
685		return 0, err
686	}
687	pairs := make([]gpu.FormatPair, len(b.fb.Formats))
688	for i, f := range b.fb.Formats {
689		pairs[i] = gpu.FormatPair{FourCC: f.FourCC, Modifier: f.Modifier}
690	}
691	return gpu.ChooseModifier(pairs, supported)
692}
693
694// dropRenderer retires the renderer and every wl object that refers to its
695// images.
696func (b *Bar) dropRenderer() {
697	if b.rend == nil {
698		return
699	}
700	for i, fd := range b.watched {
701		if fd >= 0 {
702			_ = b.conn.UnwatchFD(fd)
703			b.watched[i] = -1
704		}
705	}
706	if err := b.surf.DestroyImports(); err != nil {
707		b.log.Warn("destroy imports", "err", err)
708	}
709	b.haveAcquire = false
710	b.rend.Close()
711	b.rend = nil
712}
713
714func (b *Bar) fail(err error) {
715	if b.fatal == nil {
716		b.fatal = err
717	}
718}
719
720func (b *Bar) shutdown() error {
721	var errs []error
722	// Stop the scripts first: cancel asks them to end, Wait gives them time to,
723	// so none outlives the bar.
724	if b.cancelRun != nil {
725		b.cancelRun()
726	}
727	for _, r := range b.runners {
728		r.cancel()
729	}
730	for _, r := range b.runners {
731		r.m.Wait(3 * time.Second)
732	}
733	b.dropRenderer()
734	b.face.Close()
735	if b.node != nil {
736		errs = append(errs, b.node.Close())
737	}
738	b.dev.Close()
739	if b.conn != nil {
740		errs = append(errs, b.conn.Close())
741	}
742	return errors.Join(errs...)
743}
744
745// settleDelay is how long the bar waits for the second half of a scale or size
746// change before it rebuilds.
747const settleDelay = 60 * time.Millisecond
748
749// geometryChanged is called when the compositor reports a new scale or size.
750// The first report builds at once, because there is nothing on screen yet.
751// Later ones wait for the dust to settle, and nothing is drawn meanwhile.
752func (b *Bar) geometryChanged() {
753	if b.rend == nil && !b.fresh {
754		b.reconcile()
755		return
756	}
757	b.settling = true
758	b.settleC = time.After(settleDelay)
759}
760
761// Handler methods. Events of a replaced surface are ignored.
762
763// Configure marks the surface presentable and rebuilds as needed.
764func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
765	if id != b.sid {
766		return
767	}
768	if !b.configured {
769		b.canPresent = true
770	}
771	b.configured = true
772	b.geometryChanged()
773}
774
775// Scale follows the monitor's preferred scale.
776func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {
777	if id == b.sid {
778		b.geometryChanged()
779	}
780}
781
782// FeedbackDone records the compositor's formats; a changed main device is not
783// followed.
784func (b *Bar) FeedbackDone(id neferclient.SurfaceID) {
785	if id != b.sid {
786		return
787	}
788	fb := b.surf.Feedback()
789	if b.fb != nil && b.fb.MainDevice != fb.MainDevice {
790		b.log.Warn("dmabuf main device changed; restart the bar to follow it")
791		return
792	}
793	if b.fb.Equal(fb) {
794		return
795	}
796	b.fb = fb.Clone()
797	b.dropRenderer() // formats changed: images must be rebuilt
798	b.reconcile()
799}
800
801// Frame allows the next Present.
802func (b *Bar) Frame(id neferclient.SurfaceID) {
803	if id == b.sid {
804		b.canPresent = true
805	}
806}
807
808// Closed ends the loop when the compositor closes the surface.
809func (b *Bar) Closed(id neferclient.SurfaceID) {
810	if id == b.sid {
811		b.closed = true
812	}
813}
814
815// FDReady hands a slot back once the compositor released it.
816func (b *Bar) FDReady(id uint64) {
817	if b.rend == nil {
818		return
819	}
820	if err := b.rend.Released(int(id)); err != nil {
821		b.fail(err)
822	}
823}
824
825// Error logs non-fatal connection errors.
826func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
827
828// Unused events.
829func (b *Bar) OutputAdded(*neferclient.Output)           {}
830func (b *Bar) OutputRemoved(uint32)                      {}
831func (b *Bar) Locked()                                   {}
832func (b *Bar) LockFinished()                             {}
833func (b *Bar) Pointer(*neferclient.PointerEvent)         {}
834func (b *Bar) Key(*neferclient.KeyEvent)                 {}
835func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
836func (b *Bar) SecretChanged(int)                         {}

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 5

Tags 4

↑↓ moveenter openesc close