main 7a3b92c

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