feat/spike 551649b

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