detached 6525562

feat(bar): add layer surface with fractional-scale height, frame pacing, pprof and memstats

commit
65255621dd7cc9b35404bc023a512bdd30d2785c
author
brice · · 2026-10-05 14:08 UTC
parent
063f71c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +553 -0
A cmd/neferbar/main.go +115 -0
@@ -0,0 +1,115 @@
1+// Command neferbar is a one-cell-high, terminal-style status bar for Wayland.
2+package main
3+
4+import (
5+ "context"
6+ "flag"
7+ "fmt"
8+ "log/slog"
9+ "net"
10+ "net/http"
11+ "net/http/pprof"
12+ "os"
13+ "os/signal"
14+ "runtime"
15+ "syscall"
16+ "time"
17+
18+ "git.bnema.dev/bnema/neferbar/internal/bar"
19+ "git.bnema.dev/bnema/neferbar/internal/config"
20+)
21+
22+func main() {
23+ cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")
24+ pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060")
25+ memStats := flag.Duration("memstats", 0, "log allocation counters at this interval (0: off)")
26+ flag.Parse()
27+
28+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))
29+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
30+ defer stop()
31+ if err := run(ctx, log, *cfgPath, *pprofAddr, *memStats); err != nil {
32+ log.Error("neferbar", "err", err)
33+ os.Exit(1)
34+ }
35+}
36+
37+func run(ctx context.Context, log *slog.Logger, cfgPath, pprofAddr string, memStats time.Duration) error {
38+ explicit := cfgPath != ""
39+ if !explicit {
40+ cfgPath = config.DefaultPath()
41+ }
42+ cfg, err := config.Load(cfgPath, explicit)
43+ if err != nil {
44+ return err
45+ }
46+ if pprofAddr != "" {
47+ if err = servePprof(ctx, log, pprofAddr); err != nil {
48+ return err
49+ }
50+ }
51+ b, err := bar.New(cfg, log)
52+ if err != nil {
53+ return err
54+ }
55+ if memStats > 0 {
56+ go logMemStats(ctx, log, memStats)
57+ }
58+ err = b.Run(ctx)
59+ log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,
60+ "recreated", b.Stats.Recreated, "no_slot", b.Stats.NoSlot, "dirty", b.Stats.Dirty, "module_wakes", b.Stats.ModuleWake)
61+ return err
62+}
63+
64+// servePprof serves the pprof handlers on a loopback address only.
65+func servePprof(ctx context.Context, log *slog.Logger, addr string) error {
66+ host, _, err := net.SplitHostPort(addr)
67+ if err != nil {
68+ return fmt.Errorf("pprof address: %w", err)
69+ }
70+ if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
71+ return fmt.Errorf("pprof address %q must be loopback", addr)
72+ }
73+ ln, err := net.Listen("tcp", addr)
74+ if err != nil {
75+ return fmt.Errorf("pprof listen: %w", err)
76+ }
77+ mux := http.NewServeMux()
78+ mux.HandleFunc("/debug/pprof/", pprof.Index)
79+ mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
80+ mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
81+ mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
82+ mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
83+ // Count every allocation so the heap profile shows all of them.
84+ runtime.MemProfileRate = 1
85+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
86+ go func() {
87+ <-ctx.Done()
88+ _ = srv.Close()
89+ }()
90+ go func() {
91+ if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
92+ log.Error("pprof", "err", err)
93+ }
94+ }()
95+ log.Info("pprof listening", "addr", ln.Addr().String())
96+ return nil
97+}
98+
99+// logMemStats prints cumulative allocation counters so a steady-state run can
100+// be checked for allocations per frame.
101+func logMemStats(ctx context.Context, log *slog.Logger, every time.Duration) {
102+ t := time.NewTicker(every)
103+ defer t.Stop()
104+ var ms runtime.MemStats
105+ for {
106+ select {
107+ case <-ctx.Done():
108+ return
109+ case <-t.C:
110+ runtime.ReadMemStats(&ms)
111+ log.Info("memstats", "mallocs", ms.Mallocs, "frees", ms.Frees, "heap_alloc", ms.HeapAlloc,
112+ "total_alloc", ms.TotalAlloc, "num_gc", ms.NumGC, "goroutines", runtime.NumGoroutine())
113+ }
114+ }
115+}
@@ -0,0 +1,115 @@
1+// Command neferbar is a one-cell-high, terminal-style status bar for Wayland.
2+package main
3+
4+import (
5+ "context"
6+ "flag"
7+ "fmt"
8+ "log/slog"
9+ "net"
10+ "net/http"
11+ "net/http/pprof"
12+ "os"
13+ "os/signal"
14+ "runtime"
15+ "syscall"
16+ "time"
17+
18+ "git.bnema.dev/bnema/neferbar/internal/bar"
19+ "git.bnema.dev/bnema/neferbar/internal/config"
20+)
21+
22+func main() {
23+ cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")
24+ pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060")
25+ memStats := flag.Duration("memstats", 0, "log allocation counters at this interval (0: off)")
26+ flag.Parse()
27+
28+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))
29+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
30+ defer stop()
31+ if err := run(ctx, log, *cfgPath, *pprofAddr, *memStats); err != nil {
32+ log.Error("neferbar", "err", err)
33+ os.Exit(1)
34+ }
35+}
36+
37+func run(ctx context.Context, log *slog.Logger, cfgPath, pprofAddr string, memStats time.Duration) error {
38+ explicit := cfgPath != ""
39+ if !explicit {
40+ cfgPath = config.DefaultPath()
41+ }
42+ cfg, err := config.Load(cfgPath, explicit)
43+ if err != nil {
44+ return err
45+ }
46+ if pprofAddr != "" {
47+ if err = servePprof(ctx, log, pprofAddr); err != nil {
48+ return err
49+ }
50+ }
51+ b, err := bar.New(cfg, log)
52+ if err != nil {
53+ return err
54+ }
55+ if memStats > 0 {
56+ go logMemStats(ctx, log, memStats)
57+ }
58+ err = b.Run(ctx)
59+ log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,
60+ "recreated", b.Stats.Recreated, "no_slot", b.Stats.NoSlot, "dirty", b.Stats.Dirty, "module_wakes", b.Stats.ModuleWake)
61+ return err
62+}
63+
64+// servePprof serves the pprof handlers on a loopback address only.
65+func servePprof(ctx context.Context, log *slog.Logger, addr string) error {
66+ host, _, err := net.SplitHostPort(addr)
67+ if err != nil {
68+ return fmt.Errorf("pprof address: %w", err)
69+ }
70+ if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
71+ return fmt.Errorf("pprof address %q must be loopback", addr)
72+ }
73+ ln, err := net.Listen("tcp", addr)
74+ if err != nil {
75+ return fmt.Errorf("pprof listen: %w", err)
76+ }
77+ mux := http.NewServeMux()
78+ mux.HandleFunc("/debug/pprof/", pprof.Index)
79+ mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
80+ mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
81+ mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
82+ mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
83+ // Count every allocation so the heap profile shows all of them.
84+ runtime.MemProfileRate = 1
85+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
86+ go func() {
87+ <-ctx.Done()
88+ _ = srv.Close()
89+ }()
90+ go func() {
91+ if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
92+ log.Error("pprof", "err", err)
93+ }
94+ }()
95+ log.Info("pprof listening", "addr", ln.Addr().String())
96+ return nil
97+}
98+
99+// logMemStats prints cumulative allocation counters so a steady-state run can
100+// be checked for allocations per frame.
101+func logMemStats(ctx context.Context, log *slog.Logger, every time.Duration) {
102+ t := time.NewTicker(every)
103+ defer t.Stop()
104+ var ms runtime.MemStats
105+ for {
106+ select {
107+ case <-ctx.Done():
108+ return
109+ case <-t.C:
110+ runtime.ReadMemStats(&ms)
111+ log.Info("memstats", "mallocs", ms.Mallocs, "frees", ms.Frees, "heap_alloc", ms.HeapAlloc,
112+ "total_alloc", ms.TotalAlloc, "num_gc", ms.NumGC, "goroutines", runtime.NumGoroutine())
113+ }
114+ }
115+}
A internal/bar/bar.go +438 -0
@@ -0,0 +1,438 @@
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.
8+package bar
9+
10+import (
11+ "context"
12+ "errors"
13+ "fmt"
14+ "log/slog"
15+ "math"
16+ "path/filepath"
17+
18+ "github.com/bnema/neferclient"
19+
20+ "git.bnema.dev/bnema/neferbar/internal/config"
21+ "git.bnema.dev/bnema/neferbar/internal/glyph"
22+ "git.bnema.dev/bnema/neferbar/internal/gpu"
23+ "git.bnema.dev/bnema/neferbar/internal/layout"
24+ "git.bnema.dev/bnema/neferbar/internal/module"
25+ "git.bnema.dev/bnema/neferbar/internal/syncobj"
26+)
27+
28+const acquireTimelineID = 1 << 32
29+
30+// Stats counts what the bar did; read it only from the loop goroutine or after
31+// Run returns.
32+type Stats struct {
33+ Presented uint64 // frames handed to the compositor
34+ Rebuilds uint64 // renderer rebuilds (scale or size changes)
35+ Recreated uint64 // layer surface recreations
36+ NoSlot uint64 // draws skipped because every image was held
37+ Dirty uint64 // module wake-ups that changed the row
38+ ModuleWake uint64
39+}
40+
41+// Bar is the running application.
42+type Bar struct {
43+ cfg config.Config
44+ log *slog.Logger
45+ fg, bg [3]uint8
46+
47+ conn *neferclient.Conn
48+ surf *neferclient.Surface
49+ sid neferclient.SurfaceID
50+
51+ configured bool
52+ canPresent bool
53+ dirty bool
54+
55+ // font
56+ regularPath, boldPath string
57+ face *glyph.Face
58+ facePx float64
59+
60+ dev *gpu.Device
61+ node *syncobj.Node
62+ fb *neferclient.Feedback
63+ rend *gpu.Renderer
64+ mod gpu.Modifier
65+
66+ haveAcquire bool
67+ frame gpu.Frame
68+ watched [gpu.SlotCount]int // watched eventfds, -1 when none
69+
70+ mods []*module.Module
71+ lay *layout.Layout
72+ modCh chan struct{}
73+ Stats Stats
74+ fatal error
75+ closed bool
76+}
77+
78+// New builds a bar from a validated config.
79+func New(cfg config.Config, log *slog.Logger) (*Bar, error) {
80+ b := &Bar{cfg: cfg, log: log, modCh: make(chan struct{}, 1)}
81+ var err error
82+ if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {
83+ return nil, err
84+ }
85+ if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {
86+ return nil, err
87+ }
88+ if b.regularPath, err = glyph.FindFont(cfg.Bar.Font, false); err != nil {
89+ return nil, err
90+ }
91+ if b.boldPath, err = glyph.FindFont(cfg.Bar.Font, true); err != nil {
92+ b.boldPath = b.regularPath
93+ }
94+ for i := range b.watched {
95+ b.watched[i] = -1
96+ }
97+ zones := map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
98+ for _, m := range cfg.Module {
99+ b.mods = append(b.mods, module.New(m.Name, zones[m.Zone], m.Exec, b.modCh, log))
100+ }
101+ return b, nil
102+}
103+
104+// Run connects to the compositor and serves until ctx ends or the surface is
105+// closed. It returns the first fatal error.
106+func (b *Bar) Run(ctx context.Context) (err error) {
107+ b.conn, err = neferclient.Connect(ctx, "")
108+ if err != nil {
109+ return fmt.Errorf("connect: %w", err)
110+ }
111+ defer func() { err = errors.Join(err, b.shutdown()) }()
112+ if err = b.createSurface(estimateHeight(b.cfg.Bar.Size * b.cfg.Bar.Scale)); err != nil {
113+ return err
114+ }
115+ runCtx, cancel := context.WithCancel(ctx)
116+ defer cancel()
117+ for _, m := range b.mods {
118+ go m.Run(runCtx)
119+ }
120+ for !b.closed {
121+ select {
122+ case <-ctx.Done():
123+ return nil
124+ case <-b.conn.Wake():
125+ if err = b.conn.Dispatch(b); err != nil {
126+ return fmt.Errorf("dispatch: %w", err)
127+ }
128+ case <-b.modCh:
129+ b.Stats.ModuleWake++
130+ }
131+ if b.fatal != nil {
132+ return b.fatal
133+ }
134+ if err = b.step(); err != nil {
135+ return err
136+ }
137+ }
138+ return nil
139+}
140+
141+// estimateHeight is the first request, before the real cell height is known.
142+func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) }
143+
144+func (b *Bar) createSurface(h int32) error {
145+ surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{
146+ Output: b.cfg.Bar.Output,
147+ Namespace: "neferbar",
148+ Level: neferclient.LayerTop,
149+ Anchors: neferclient.AnchorTop | neferclient.AnchorLeft | neferclient.AnchorRight,
150+ ExclusiveZone: h,
151+ Height: h,
152+ InputRects: []neferclient.Rect{}, // read only: click-through
153+ })
154+ if err != nil {
155+ return fmt.Errorf("layer surface: %w", err)
156+ }
157+ b.surf, b.sid = surf, surf.ID()
158+ b.configured, b.canPresent = false, false
159+ return nil
160+}
161+
162+// step reparses changed modules and draws when something is ready.
163+func (b *Bar) step() error {
164+ if b.lay != nil && b.lay.Update() {
165+ b.dirty = true
166+ b.Stats.Dirty++
167+ }
168+ return b.draw()
169+}
170+
171+func (b *Bar) draw() error {
172+ if !b.dirty || !b.canPresent || b.rend == nil {
173+ return nil
174+ }
175+ ok, err := b.rend.Draw(b.lay.Compose(), b.bg, &b.frame)
176+ if err != nil {
177+ return fmt.Errorf("draw: %w", err)
178+ }
179+ if !ok {
180+ b.Stats.NoSlot++ // Released retries
181+ return nil
182+ }
183+ if err = b.present(); err != nil {
184+ return err
185+ }
186+ b.dirty, b.canPresent = false, false
187+ b.Stats.Presented++
188+ return nil
189+}
190+
191+func (b *Bar) present() error {
192+ f := &b.frame
193+ if f.NewBuffer {
194+ img := f.Image
195+ buf := neferclient.Buffer{
196+ Width: img.Width, Height: img.Height, FourCC: gpu.DRMFormatXRGB8888, Modifier: img.Modifier, PlaneCount: 1,
197+ }
198+ buf.Planes[0] = neferclient.Plane{FD: img.FD, Offset: img.Offset, Stride: img.Stride}
199+ if err := b.surf.ImportBuffer(uint64(f.Slot), &buf); err != nil {
200+ return fmt.Errorf("import buffer: %w", err)
201+ }
202+ if err := b.surf.ImportTimeline(uint64(f.Slot), f.ReleaseTimeline); err != nil {
203+ return fmt.Errorf("import release timeline: %w", err)
204+ }
205+ if err := b.conn.WatchFD(f.ReleaseEventFD, uint64(f.Slot)); err != nil {
206+ return fmt.Errorf("watch release fd: %w", err)
207+ }
208+ b.watched[f.Slot] = f.ReleaseEventFD
209+ }
210+ if !b.haveAcquire {
211+ if err := b.surf.ImportTimeline(acquireTimelineID, b.rend.AcquireTimelineFD()); err != nil {
212+ return fmt.Errorf("import acquire timeline: %w", err)
213+ }
214+ b.haveAcquire = true
215+ }
216+ err := b.surf.Present(&neferclient.Present{
217+ Buffer: uint64(f.Slot),
218+ AcquireTimeline: acquireTimelineID,
219+ ReleaseTimeline: uint64(f.Slot),
220+ AcquirePoint: f.AcquirePoint,
221+ ReleasePoint: f.ReleasePoint,
222+ Opaque: true,
223+ })
224+ if err != nil {
225+ return fmt.Errorf("present: %w", err)
226+ }
227+ return nil
228+}
229+
230+// reconcile brings the font, the layer surface height and the renderer in
231+// line with the current scale and feedback. It runs on every event that can
232+// change them and does nothing when everything already matches.
233+func (b *Bar) reconcile() {
234+ if !b.configured || b.fb == nil {
235+ return
236+ }
237+ if b.dev == nil {
238+ dev, err := gpu.Open(b.fb.MainDevice)
239+ if err != nil {
240+ b.fail(err)
241+ return
242+ }
243+ node, err := syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", dev.RenderMinor)))
244+ if err != nil {
245+ dev.Close()
246+ b.fail(err)
247+ return
248+ }
249+ b.dev, b.node = dev, node
250+ }
251+ _, h, scale := b.surf.Size()
252+ px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale
253+ if b.face == nil || b.facePx != px {
254+ face, err := glyph.Load(b.regularPath, b.boldPath, px)
255+ if err != nil {
256+ b.fail(err)
257+ return
258+ }
259+ b.dropRenderer()
260+ b.face.Close()
261+ b.face, b.facePx = face, px
262+ }
263+ // The bar is one cell high: logical height = ceil(cell / scale).
264+ wantH := int32(math.Ceil(float64(b.face.CellH)/scale - 1e-9))
265+ if h != wantH {
266+ b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
267+ b.dropRenderer()
268+ if err := b.surf.Close(); err != nil {
269+ b.fail(err)
270+ return
271+ }
272+ if err := b.createSurface(wantH); err != nil {
273+ b.fail(err)
274+ return
275+ }
276+ b.Stats.Recreated++
277+ return
278+ }
279+ pw, ph, err := b.surf.PhysicalSize()
280+ if err != nil {
281+ b.fail(err)
282+ return
283+ }
284+ if b.rend != nil && b.rend.W == pw && b.rend.H == ph {
285+ return
286+ }
287+ b.dropRenderer()
288+ if mod, err := b.chooseModifier(); err != nil {
289+ b.fail(err)
290+ return
291+ } else {
292+ b.mod = mod
293+ }
294+ rend, err := gpu.NewRenderer(b.dev, b.node, b.mod, b.face, pw, ph)
295+ if err != nil {
296+ b.fail(err)
297+ return
298+ }
299+ b.rend = rend
300+ b.Stats.Rebuilds++
301+ if b.lay == nil {
302+ b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.mods)
303+ } else {
304+ b.lay.Resize(rend.Cols())
305+ }
306+ b.lay.Update()
307+ b.dirty = true
308+ 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())
309+}
310+
311+func (b *Bar) chooseModifier() (gpu.Modifier, error) {
312+ supported, err := b.dev.ExportableModifiers()
313+ if err != nil {
314+ return 0, err
315+ }
316+ pairs := make([]gpu.FormatPair, len(b.fb.Formats))
317+ for i, f := range b.fb.Formats {
318+ pairs[i] = gpu.FormatPair{FourCC: f.FourCC, Modifier: f.Modifier}
319+ }
320+ return gpu.ChooseModifier(pairs, supported)
321+}
322+
323+// dropRenderer retires the renderer and every wl object that refers to its
324+// images.
325+func (b *Bar) dropRenderer() {
326+ if b.rend == nil {
327+ return
328+ }
329+ for i, fd := range b.watched {
330+ if fd >= 0 {
331+ _ = b.conn.UnwatchFD(fd)
332+ b.watched[i] = -1
333+ }
334+ }
335+ if err := b.surf.DestroyImports(); err != nil {
336+ b.log.Warn("destroy imports", "err", err)
337+ }
338+ b.haveAcquire = false
339+ b.rend.Close()
340+ b.rend = nil
341+}
342+
343+func (b *Bar) fail(err error) {
344+ if b.fatal == nil {
345+ b.fatal = err
346+ }
347+}
348+
349+func (b *Bar) shutdown() error {
350+ var errs []error
351+ b.dropRenderer()
352+ b.face.Close()
353+ if b.node != nil {
354+ errs = append(errs, b.node.Close())
355+ }
356+ b.dev.Close()
357+ if b.conn != nil {
358+ errs = append(errs, b.conn.Close())
359+ }
360+ return errors.Join(errs...)
361+}
362+
363+// Handler methods. Events of a replaced surface are ignored.
364+
365+// Configure marks the surface presentable and rebuilds as needed.
366+func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
367+ if id != b.sid {
368+ return
369+ }
370+ if !b.configured {
371+ b.canPresent = true
372+ }
373+ b.configured = true
374+ b.reconcile()
375+}
376+
377+// Scale follows the monitor's preferred scale.
378+func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {
379+ if id == b.sid {
380+ b.reconcile()
381+ }
382+}
383+
384+// FeedbackDone records the compositor's formats; a changed main device is not
385+// followed.
386+func (b *Bar) FeedbackDone(id neferclient.SurfaceID) {
387+ if id != b.sid {
388+ return
389+ }
390+ fb := b.surf.Feedback()
391+ if b.fb != nil && b.fb.MainDevice != fb.MainDevice {
392+ b.log.Warn("dmabuf main device changed; restart the bar to follow it")
393+ return
394+ }
395+ if b.fb.Equal(fb) {
396+ return
397+ }
398+ b.fb = fb.Clone()
399+ b.dropRenderer() // formats changed: images must be rebuilt
400+ b.reconcile()
401+}
402+
403+// Frame allows the next Present.
404+func (b *Bar) Frame(id neferclient.SurfaceID) {
405+ if id == b.sid {
406+ b.canPresent = true
407+ }
408+}
409+
410+// Closed ends the loop when the compositor closes the surface.
411+func (b *Bar) Closed(id neferclient.SurfaceID) {
412+ if id == b.sid {
413+ b.closed = true
414+ }
415+}
416+
417+// FDReady hands a slot back once the compositor released it.
418+func (b *Bar) FDReady(id uint64) {
419+ if b.rend == nil {
420+ return
421+ }
422+ if err := b.rend.Released(int(id)); err != nil {
423+ b.fail(err)
424+ }
425+}
426+
427+// Error logs non-fatal connection errors.
428+func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
429+
430+// Unused events.
431+func (b *Bar) OutputAdded(*neferclient.Output) {}
432+func (b *Bar) OutputRemoved(uint32) {}
433+func (b *Bar) Locked() {}
434+func (b *Bar) LockFinished() {}
435+func (b *Bar) Pointer(*neferclient.PointerEvent) {}
436+func (b *Bar) Key(*neferclient.KeyEvent) {}
437+func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
438+func (b *Bar) SecretChanged(int) {}
@@ -0,0 +1,438 @@
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.
8+package bar
9+
10+import (
11+ "context"
12+ "errors"
13+ "fmt"
14+ "log/slog"
15+ "math"
16+ "path/filepath"
17+
18+ "github.com/bnema/neferclient"
19+
20+ "git.bnema.dev/bnema/neferbar/internal/config"
21+ "git.bnema.dev/bnema/neferbar/internal/glyph"
22+ "git.bnema.dev/bnema/neferbar/internal/gpu"
23+ "git.bnema.dev/bnema/neferbar/internal/layout"
24+ "git.bnema.dev/bnema/neferbar/internal/module"
25+ "git.bnema.dev/bnema/neferbar/internal/syncobj"
26+)
27+
28+const acquireTimelineID = 1 << 32
29+
30+// Stats counts what the bar did; read it only from the loop goroutine or after
31+// Run returns.
32+type Stats struct {
33+ Presented uint64 // frames handed to the compositor
34+ Rebuilds uint64 // renderer rebuilds (scale or size changes)
35+ Recreated uint64 // layer surface recreations
36+ NoSlot uint64 // draws skipped because every image was held
37+ Dirty uint64 // module wake-ups that changed the row
38+ ModuleWake uint64
39+}
40+
41+// Bar is the running application.
42+type Bar struct {
43+ cfg config.Config
44+ log *slog.Logger
45+ fg, bg [3]uint8
46+
47+ conn *neferclient.Conn
48+ surf *neferclient.Surface
49+ sid neferclient.SurfaceID
50+
51+ configured bool
52+ canPresent bool
53+ dirty bool
54+
55+ // font
56+ regularPath, boldPath string
57+ face *glyph.Face
58+ facePx float64
59+
60+ dev *gpu.Device
61+ node *syncobj.Node
62+ fb *neferclient.Feedback
63+ rend *gpu.Renderer
64+ mod gpu.Modifier
65+
66+ haveAcquire bool
67+ frame gpu.Frame
68+ watched [gpu.SlotCount]int // watched eventfds, -1 when none
69+
70+ mods []*module.Module
71+ lay *layout.Layout
72+ modCh chan struct{}
73+ Stats Stats
74+ fatal error
75+ closed bool
76+}
77+
78+// New builds a bar from a validated config.
79+func New(cfg config.Config, log *slog.Logger) (*Bar, error) {
80+ b := &Bar{cfg: cfg, log: log, modCh: make(chan struct{}, 1)}
81+ var err error
82+ if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {
83+ return nil, err
84+ }
85+ if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {
86+ return nil, err
87+ }
88+ if b.regularPath, err = glyph.FindFont(cfg.Bar.Font, false); err != nil {
89+ return nil, err
90+ }
91+ if b.boldPath, err = glyph.FindFont(cfg.Bar.Font, true); err != nil {
92+ b.boldPath = b.regularPath
93+ }
94+ for i := range b.watched {
95+ b.watched[i] = -1
96+ }
97+ zones := map[string]module.Zone{"left": module.Left, "center": module.Center, "right": module.Right}
98+ for _, m := range cfg.Module {
99+ b.mods = append(b.mods, module.New(m.Name, zones[m.Zone], m.Exec, b.modCh, log))
100+ }
101+ return b, nil
102+}
103+
104+// Run connects to the compositor and serves until ctx ends or the surface is
105+// closed. It returns the first fatal error.
106+func (b *Bar) Run(ctx context.Context) (err error) {
107+ b.conn, err = neferclient.Connect(ctx, "")
108+ if err != nil {
109+ return fmt.Errorf("connect: %w", err)
110+ }
111+ defer func() { err = errors.Join(err, b.shutdown()) }()
112+ if err = b.createSurface(estimateHeight(b.cfg.Bar.Size * b.cfg.Bar.Scale)); err != nil {
113+ return err
114+ }
115+ runCtx, cancel := context.WithCancel(ctx)
116+ defer cancel()
117+ for _, m := range b.mods {
118+ go m.Run(runCtx)
119+ }
120+ for !b.closed {
121+ select {
122+ case <-ctx.Done():
123+ return nil
124+ case <-b.conn.Wake():
125+ if err = b.conn.Dispatch(b); err != nil {
126+ return fmt.Errorf("dispatch: %w", err)
127+ }
128+ case <-b.modCh:
129+ b.Stats.ModuleWake++
130+ }
131+ if b.fatal != nil {
132+ return b.fatal
133+ }
134+ if err = b.step(); err != nil {
135+ return err
136+ }
137+ }
138+ return nil
139+}
140+
141+// estimateHeight is the first request, before the real cell height is known.
142+func estimateHeight(px float64) int32 { return int32(math.Ceil(px * 1.5)) }
143+
144+func (b *Bar) createSurface(h int32) error {
145+ surf, err := b.conn.NewLayerSurface(neferclient.LayerConfig{
146+ Output: b.cfg.Bar.Output,
147+ Namespace: "neferbar",
148+ Level: neferclient.LayerTop,
149+ Anchors: neferclient.AnchorTop | neferclient.AnchorLeft | neferclient.AnchorRight,
150+ ExclusiveZone: h,
151+ Height: h,
152+ InputRects: []neferclient.Rect{}, // read only: click-through
153+ })
154+ if err != nil {
155+ return fmt.Errorf("layer surface: %w", err)
156+ }
157+ b.surf, b.sid = surf, surf.ID()
158+ b.configured, b.canPresent = false, false
159+ return nil
160+}
161+
162+// step reparses changed modules and draws when something is ready.
163+func (b *Bar) step() error {
164+ if b.lay != nil && b.lay.Update() {
165+ b.dirty = true
166+ b.Stats.Dirty++
167+ }
168+ return b.draw()
169+}
170+
171+func (b *Bar) draw() error {
172+ if !b.dirty || !b.canPresent || b.rend == nil {
173+ return nil
174+ }
175+ ok, err := b.rend.Draw(b.lay.Compose(), b.bg, &b.frame)
176+ if err != nil {
177+ return fmt.Errorf("draw: %w", err)
178+ }
179+ if !ok {
180+ b.Stats.NoSlot++ // Released retries
181+ return nil
182+ }
183+ if err = b.present(); err != nil {
184+ return err
185+ }
186+ b.dirty, b.canPresent = false, false
187+ b.Stats.Presented++
188+ return nil
189+}
190+
191+func (b *Bar) present() error {
192+ f := &b.frame
193+ if f.NewBuffer {
194+ img := f.Image
195+ buf := neferclient.Buffer{
196+ Width: img.Width, Height: img.Height, FourCC: gpu.DRMFormatXRGB8888, Modifier: img.Modifier, PlaneCount: 1,
197+ }
198+ buf.Planes[0] = neferclient.Plane{FD: img.FD, Offset: img.Offset, Stride: img.Stride}
199+ if err := b.surf.ImportBuffer(uint64(f.Slot), &buf); err != nil {
200+ return fmt.Errorf("import buffer: %w", err)
201+ }
202+ if err := b.surf.ImportTimeline(uint64(f.Slot), f.ReleaseTimeline); err != nil {
203+ return fmt.Errorf("import release timeline: %w", err)
204+ }
205+ if err := b.conn.WatchFD(f.ReleaseEventFD, uint64(f.Slot)); err != nil {
206+ return fmt.Errorf("watch release fd: %w", err)
207+ }
208+ b.watched[f.Slot] = f.ReleaseEventFD
209+ }
210+ if !b.haveAcquire {
211+ if err := b.surf.ImportTimeline(acquireTimelineID, b.rend.AcquireTimelineFD()); err != nil {
212+ return fmt.Errorf("import acquire timeline: %w", err)
213+ }
214+ b.haveAcquire = true
215+ }
216+ err := b.surf.Present(&neferclient.Present{
217+ Buffer: uint64(f.Slot),
218+ AcquireTimeline: acquireTimelineID,
219+ ReleaseTimeline: uint64(f.Slot),
220+ AcquirePoint: f.AcquirePoint,
221+ ReleasePoint: f.ReleasePoint,
222+ Opaque: true,
223+ })
224+ if err != nil {
225+ return fmt.Errorf("present: %w", err)
226+ }
227+ return nil
228+}
229+
230+// reconcile brings the font, the layer surface height and the renderer in
231+// line with the current scale and feedback. It runs on every event that can
232+// change them and does nothing when everything already matches.
233+func (b *Bar) reconcile() {
234+ if !b.configured || b.fb == nil {
235+ return
236+ }
237+ if b.dev == nil {
238+ dev, err := gpu.Open(b.fb.MainDevice)
239+ if err != nil {
240+ b.fail(err)
241+ return
242+ }
243+ node, err := syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", dev.RenderMinor)))
244+ if err != nil {
245+ dev.Close()
246+ b.fail(err)
247+ return
248+ }
249+ b.dev, b.node = dev, node
250+ }
251+ _, h, scale := b.surf.Size()
252+ px := b.cfg.Bar.Size * b.cfg.Bar.Scale * scale
253+ if b.face == nil || b.facePx != px {
254+ face, err := glyph.Load(b.regularPath, b.boldPath, px)
255+ if err != nil {
256+ b.fail(err)
257+ return
258+ }
259+ b.dropRenderer()
260+ b.face.Close()
261+ b.face, b.facePx = face, px
262+ }
263+ // The bar is one cell high: logical height = ceil(cell / scale).
264+ wantH := int32(math.Ceil(float64(b.face.CellH)/scale - 1e-9))
265+ if h != wantH {
266+ b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
267+ b.dropRenderer()
268+ if err := b.surf.Close(); err != nil {
269+ b.fail(err)
270+ return
271+ }
272+ if err := b.createSurface(wantH); err != nil {
273+ b.fail(err)
274+ return
275+ }
276+ b.Stats.Recreated++
277+ return
278+ }
279+ pw, ph, err := b.surf.PhysicalSize()
280+ if err != nil {
281+ b.fail(err)
282+ return
283+ }
284+ if b.rend != nil && b.rend.W == pw && b.rend.H == ph {
285+ return
286+ }
287+ b.dropRenderer()
288+ if mod, err := b.chooseModifier(); err != nil {
289+ b.fail(err)
290+ return
291+ } else {
292+ b.mod = mod
293+ }
294+ rend, err := gpu.NewRenderer(b.dev, b.node, b.mod, b.face, pw, ph)
295+ if err != nil {
296+ b.fail(err)
297+ return
298+ }
299+ b.rend = rend
300+ b.Stats.Rebuilds++
301+ if b.lay == nil {
302+ b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.mods)
303+ } else {
304+ b.lay.Resize(rend.Cols())
305+ }
306+ b.lay.Update()
307+ b.dirty = true
308+ 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())
309+}
310+
311+func (b *Bar) chooseModifier() (gpu.Modifier, error) {
312+ supported, err := b.dev.ExportableModifiers()
313+ if err != nil {
314+ return 0, err
315+ }
316+ pairs := make([]gpu.FormatPair, len(b.fb.Formats))
317+ for i, f := range b.fb.Formats {
318+ pairs[i] = gpu.FormatPair{FourCC: f.FourCC, Modifier: f.Modifier}
319+ }
320+ return gpu.ChooseModifier(pairs, supported)
321+}
322+
323+// dropRenderer retires the renderer and every wl object that refers to its
324+// images.
325+func (b *Bar) dropRenderer() {
326+ if b.rend == nil {
327+ return
328+ }
329+ for i, fd := range b.watched {
330+ if fd >= 0 {
331+ _ = b.conn.UnwatchFD(fd)
332+ b.watched[i] = -1
333+ }
334+ }
335+ if err := b.surf.DestroyImports(); err != nil {
336+ b.log.Warn("destroy imports", "err", err)
337+ }
338+ b.haveAcquire = false
339+ b.rend.Close()
340+ b.rend = nil
341+}
342+
343+func (b *Bar) fail(err error) {
344+ if b.fatal == nil {
345+ b.fatal = err
346+ }
347+}
348+
349+func (b *Bar) shutdown() error {
350+ var errs []error
351+ b.dropRenderer()
352+ b.face.Close()
353+ if b.node != nil {
354+ errs = append(errs, b.node.Close())
355+ }
356+ b.dev.Close()
357+ if b.conn != nil {
358+ errs = append(errs, b.conn.Close())
359+ }
360+ return errors.Join(errs...)
361+}
362+
363+// Handler methods. Events of a replaced surface are ignored.
364+
365+// Configure marks the surface presentable and rebuilds as needed.
366+func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
367+ if id != b.sid {
368+ return
369+ }
370+ if !b.configured {
371+ b.canPresent = true
372+ }
373+ b.configured = true
374+ b.reconcile()
375+}
376+
377+// Scale follows the monitor's preferred scale.
378+func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {
379+ if id == b.sid {
380+ b.reconcile()
381+ }
382+}
383+
384+// FeedbackDone records the compositor's formats; a changed main device is not
385+// followed.
386+func (b *Bar) FeedbackDone(id neferclient.SurfaceID) {
387+ if id != b.sid {
388+ return
389+ }
390+ fb := b.surf.Feedback()
391+ if b.fb != nil && b.fb.MainDevice != fb.MainDevice {
392+ b.log.Warn("dmabuf main device changed; restart the bar to follow it")
393+ return
394+ }
395+ if b.fb.Equal(fb) {
396+ return
397+ }
398+ b.fb = fb.Clone()
399+ b.dropRenderer() // formats changed: images must be rebuilt
400+ b.reconcile()
401+}
402+
403+// Frame allows the next Present.
404+func (b *Bar) Frame(id neferclient.SurfaceID) {
405+ if id == b.sid {
406+ b.canPresent = true
407+ }
408+}
409+
410+// Closed ends the loop when the compositor closes the surface.
411+func (b *Bar) Closed(id neferclient.SurfaceID) {
412+ if id == b.sid {
413+ b.closed = true
414+ }
415+}
416+
417+// FDReady hands a slot back once the compositor released it.
418+func (b *Bar) FDReady(id uint64) {
419+ if b.rend == nil {
420+ return
421+ }
422+ if err := b.rend.Released(int(id)); err != nil {
423+ b.fail(err)
424+ }
425+}
426+
427+// Error logs non-fatal connection errors.
428+func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
429+
430+// Unused events.
431+func (b *Bar) OutputAdded(*neferclient.Output) {}
432+func (b *Bar) OutputRemoved(uint32) {}
433+func (b *Bar) Locked() {}
434+func (b *Bar) LockFinished() {}
435+func (b *Bar) Pointer(*neferclient.PointerEvent) {}
436+func (b *Bar) Key(*neferclient.KeyEvent) {}
437+func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
438+func (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