detached 6a789b4

fix(bar): stop scripts and their children on exit, read relative themes from the config dir, and stop recreate loops

commit
6a789b466bc4b7aaf18c3e7ec539f9daee313f21
author
brice · · 2026-10-05 16:00 UTC
parent
44d4d49
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +67 -24
M cmd/neferbar/main.go +6 -2
@@ -49,11 +49,10 @@ func run(ctx context.Context, log *slog.Logger, cfgPath, display, pprofAddr stri
4949 return err
5050 }
5151 }
52- b, err := bar.New(cfg, log, display)
52+ b, err := bar.New(cfg, cfgPath, log, display)
5353 if err != nil {
5454 return err
5555 }
56- b.WatchConfig(cfgPath)
5756 if memStats > 0 {
5857 go logMemStats(ctx, log, memStats)
5958 }
@@ -73,6 +72,11 @@ func servePprof(ctx context.Context, log *slog.Logger, addr string) error {
7372 if err != nil {
7473 return fmt.Errorf("pprof listen: %w", err)
7574 }
75+ // "localhost" is resolved by the system: trust the address actually bound.
76+ if tcp, ok := ln.Addr().(*net.TCPAddr); !ok || !tcp.IP.IsLoopback() {
77+ _ = ln.Close()
78+ return fmt.Errorf("pprof address %q is not loopback; refusing to serve profiles on it", addr)
79+ }
7680 mux := http.NewServeMux()
7781 mux.HandleFunc("/debug/pprof/", pprof.Index)
7882 mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
@@ -49,11 +49,10 @@ func run(ctx context.Context, log *slog.Logger, cfgPath, display, pprofAddr stri
49 return err49 return err
50 }50 }
51 }51 }
52- b, err := bar.New(cfg, log, display)52+ b, err := bar.New(cfg, cfgPath, log, display)
53 if err != nil {53 if err != nil {
54 return err54 return err
55 }55 }
56- b.WatchConfig(cfgPath)
57 if memStats > 0 {56 if memStats > 0 {
58 go logMemStats(ctx, log, memStats)57 go logMemStats(ctx, log, memStats)
59 }58 }
@@ -73,6 +72,11 @@ func servePprof(ctx context.Context, log *slog.Logger, addr string) error {
73 if err != nil {72 if err != nil {
74 return fmt.Errorf("pprof listen: %w", err)73 return fmt.Errorf("pprof listen: %w", err)
75 }74 }
75+ // "localhost" is resolved by the system: trust the address actually bound.
76+ if tcp, ok := ln.Addr().(*net.TCPAddr); !ok || !tcp.IP.IsLoopback() {
77+ _ = ln.Close()
78+ return fmt.Errorf("pprof address %q is not loopback; refusing to serve profiles on it", addr)
79+ }
76 mux := http.NewServeMux()80 mux := http.NewServeMux()
77 mux.HandleFunc("/debug/pprof/", pprof.Index)81 mux.HandleFunc("/debug/pprof/", pprof.Index)
78 mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)82 mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
M internal/bar/bar.go +58 -20
@@ -75,13 +75,14 @@ type Bar struct {
7575 frame gpu.Frame
7676 watched [gpu.SlotCount]int // watched eventfds, -1 when none
7777
78- runners []runner
79- mods []*module.Module // the modules of runners, in config order
80- runCtx context.Context
81- lay *layout.Layout
82- modCh chan struct{}
83- cfgPath string
84- cfgCh chan config.Config
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
8586
8687 theme theme.Theme
8788 themeCh chan struct{}
@@ -96,6 +97,16 @@ type Bar struct {
9697 settling bool
9798 settleC <-chan time.Time
9899
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+
99110 // mappedW is the logical width the current layer surface was first drawn at.
100111 // It resets to 0 when the surface is recreated.
101112 mappedW int32
@@ -104,10 +115,12 @@ type Bar struct {
104115 closed bool
105116 }
106117
107-// New builds a bar from a validated config.
108-// display names the Wayland socket; empty uses $WAYLAND_DISPLAY.
109-func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
110- b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),
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.
122+func 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),
111124 themeCh: make(chan struct{}, 1)}
112125 var err error
113126 if err = b.loadTheme(); err != nil {
@@ -145,7 +158,7 @@ func (b *Bar) loadTheme() error {
145158 }
146159 th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
147160 if err != nil {
148- b.log.Warn("theme not found; using the built-in colors", "theme", b.cfg.Bar.Theme, "err", err)
161+ b.log.Warn("using the built-in colors: "+err.Error(), "theme", b.cfg.Bar.Theme)
149162 } else {
150163 b.log.Info("theme", "source", th.Source)
151164 }
@@ -178,7 +191,11 @@ func (b *Bar) watchTheme() {
178191 ctx, cancel := context.WithCancel(b.runCtx)
179192 b.themeCancel = cancel
180193 for _, f := range b.theme.Files {
181- go func() { _ = fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh) }()
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+ }()
182199 }
183200 }
184201
@@ -194,6 +211,11 @@ func (b *Bar) reloadTheme() {
194211 return
195212 }
196213 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+ }
197219 return
198220 }
199221 b.log.Info("theme changed", "source", th.Source)
@@ -201,10 +223,6 @@ func (b *Bar) reloadTheme() {
201223 b.syncModules(b.cfg.Module) // scripts get the new colors in their environment
202224 }
203225
204-// WatchConfig makes Run reload the config file at path when it changes. Call
205-// it before Run.
206-func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
207-
208226 // runner is one started module and how to stop it.
209227 type runner struct {
210228 cfg config.Module
@@ -236,7 +254,7 @@ func (b *Bar) syncModules(want []config.Module) {
236254 ctx, cancel := context.WithCancel(b.runCtx)
237255 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
238256 m.Env = b.env
239- go m.Run(ctx)
257+ m.Start(ctx)
240258 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
241259 }
242260 for _, r := range old {
@@ -299,7 +317,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
299317 }
300318 runCtx, cancel := context.WithCancel(ctx)
301319 defer cancel()
302- b.runCtx = runCtx
320+ b.runCtx, b.cancelRun = runCtx, cancel
303321 b.watchTheme()
304322 b.syncModules(b.cfg.Module)
305323 if b.cfgPath != "" {
@@ -396,6 +414,7 @@ func (b *Bar) createSurface(h int32) error {
396414 }
397415 b.surf, b.sid = surf, surf.ID()
398416 b.mappedW = 0
417+ b.fresh = true
399418 b.configured, b.canPresent = false, false
400419 return nil
401420 }
@@ -503,7 +522,14 @@ func (b *Bar) reconcile() {
503522 }
504523 // The bar is one cell high: logical height = ceil(cell / scale).
505524 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+ }
506531 if h != wantH {
532+ b.askedH, b.askedScale = wantH, scale
507533 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
508534 b.dropRenderer()
509535 if err := b.surf.Close(); err != nil {
@@ -557,6 +583,7 @@ func (b *Bar) reconcile() {
557583 return
558584 }
559585 b.rend, b.mappedW = rend, w
586+ b.fresh = false
560587 b.Stats.Rebuilds++
561588 if b.lay == nil {
562589 b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.pal, b.mods)
@@ -608,6 +635,17 @@ func (b *Bar) fail(err error) {
608635
609636 func (b *Bar) shutdown() error {
610637 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+ }
611649 b.dropRenderer()
612650 b.face.Close()
613651 if b.node != nil {
@@ -628,7 +666,7 @@ const settleDelay = 60 * time.Millisecond
628666 // The first report builds at once, because there is nothing on screen yet.
629667 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.
630668 func (b *Bar) geometryChanged() {
631- if b.rend == nil {
669+ if b.rend == nil && !b.fresh {
632670 b.reconcile()
633671 return
634672 }
@@ -75,13 +75,14 @@ type Bar struct {
75 frame gpu.Frame75 frame gpu.Frame
76 watched [gpu.SlotCount]int // watched eventfds, -1 when none76 watched [gpu.SlotCount]int // watched eventfds, -1 when none
77 77
78- runners []runner78+ runners []runner
79- mods []*module.Module // the modules of runners, in config order79+ mods []*module.Module // the modules of runners, in config order
80- runCtx context.Context80+ runCtx context.Context
81- lay *layout.Layout81+ cancelRun context.CancelFunc
82- modCh chan struct{}82+ lay *layout.Layout
83- cfgPath string83+ modCh chan struct{}
84- cfgCh chan config.Config84+ cfgPath string
85+ cfgCh chan config.Config
85 86
86 theme theme.Theme87 theme theme.Theme
87 themeCh chan struct{}88 themeCh chan struct{}
@@ -96,6 +97,16 @@ type Bar struct {
96 settling bool97 settling bool
97 settleC <-chan time.Time98 settleC <-chan time.Time
98 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+
99 // mappedW is the logical width the current layer surface was first drawn at.110 // mappedW is the logical width the current layer surface was first drawn at.
100 // It resets to 0 when the surface is recreated.111 // It resets to 0 when the surface is recreated.
101 mappedW int32112 mappedW int32
@@ -104,10 +115,12 @@ type Bar struct {
104 closed bool115 closed bool
105 }116 }
106 117
107-// New builds a bar from a validated config.118+// New builds a bar from a validated config. cfgPath is the file the config came
108-// display names the Wayland socket; empty uses $WAYLAND_DISPLAY.119+// from: relative theme paths are read from its directory, and the bar reloads
109-func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {120+// it when it changes (empty: no reload). display names the Wayland socket;
110- b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),121+// empty uses $WAYLAND_DISPLAY.
122+func 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),
111 themeCh: make(chan struct{}, 1)}124 themeCh: make(chan struct{}, 1)}
112 var err error125 var err error
113 if err = b.loadTheme(); err != nil {126 if err = b.loadTheme(); err != nil {
@@ -145,7 +158,7 @@ func (b *Bar) loadTheme() error {
145 }158 }
146 th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())159 th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
147 if err != nil {160 if err != nil {
148- b.log.Warn("theme not found; using the built-in colors", "theme", b.cfg.Bar.Theme, "err", err)161+ b.log.Warn("using the built-in colors: "+err.Error(), "theme", b.cfg.Bar.Theme)
149 } else {162 } else {
150 b.log.Info("theme", "source", th.Source)163 b.log.Info("theme", "source", th.Source)
151 }164 }
@@ -178,7 +191,11 @@ func (b *Bar) watchTheme() {
178 ctx, cancel := context.WithCancel(b.runCtx)191 ctx, cancel := context.WithCancel(b.runCtx)
179 b.themeCancel = cancel192 b.themeCancel = cancel
180 for _, f := range b.theme.Files {193 for _, f := range b.theme.Files {
181- go func() { _ = fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh) }()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+ }()
182 }199 }
183 }200 }
184 201
@@ -194,6 +211,11 @@ func (b *Bar) reloadTheme() {
194 return211 return
195 }212 }
196 if th.Equal(b.theme) {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+ }
197 return219 return
198 }220 }
199 b.log.Info("theme changed", "source", th.Source)221 b.log.Info("theme changed", "source", th.Source)
@@ -201,10 +223,6 @@ func (b *Bar) reloadTheme() {
201 b.syncModules(b.cfg.Module) // scripts get the new colors in their environment223 b.syncModules(b.cfg.Module) // scripts get the new colors in their environment
202 }224 }
203 225
204-// WatchConfig makes Run reload the config file at path when it changes. Call
205-// it before Run.
206-func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
207-
208 // runner is one started module and how to stop it.226 // runner is one started module and how to stop it.
209 type runner struct {227 type runner struct {
210 cfg config.Module228 cfg config.Module
@@ -236,7 +254,7 @@ func (b *Bar) syncModules(want []config.Module) {
236 ctx, cancel := context.WithCancel(b.runCtx)254 ctx, cancel := context.WithCancel(b.runCtx)
237 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)255 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
238 m.Env = b.env256 m.Env = b.env
239- go m.Run(ctx)257+ m.Start(ctx)
240 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})258 next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
241 }259 }
242 for _, r := range old {260 for _, r := range old {
@@ -299,7 +317,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
299 }317 }
300 runCtx, cancel := context.WithCancel(ctx)318 runCtx, cancel := context.WithCancel(ctx)
301 defer cancel()319 defer cancel()
302- b.runCtx = runCtx320+ b.runCtx, b.cancelRun = runCtx, cancel
303 b.watchTheme()321 b.watchTheme()
304 b.syncModules(b.cfg.Module)322 b.syncModules(b.cfg.Module)
305 if b.cfgPath != "" {323 if b.cfgPath != "" {
@@ -396,6 +414,7 @@ func (b *Bar) createSurface(h int32) error {
396 }414 }
397 b.surf, b.sid = surf, surf.ID()415 b.surf, b.sid = surf, surf.ID()
398 b.mappedW = 0416 b.mappedW = 0
417+ b.fresh = true
399 b.configured, b.canPresent = false, false418 b.configured, b.canPresent = false, false
400 return nil419 return nil
401 }420 }
@@ -503,7 +522,14 @@ func (b *Bar) reconcile() {
503 }522 }
504 // The bar is one cell high: logical height = ceil(cell / scale).523 // The bar is one cell high: logical height = ceil(cell / scale).
505 wantH := int32(math.Ceil(float64(b.face.CellH)/scale - 1e-9))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+ }
506 if h != wantH {531 if h != wantH {
532+ b.askedH, b.askedScale = wantH, scale
507 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)533 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
508 b.dropRenderer()534 b.dropRenderer()
509 if err := b.surf.Close(); err != nil {535 if err := b.surf.Close(); err != nil {
@@ -557,6 +583,7 @@ func (b *Bar) reconcile() {
557 return583 return
558 }584 }
559 b.rend, b.mappedW = rend, w585 b.rend, b.mappedW = rend, w
586+ b.fresh = false
560 b.Stats.Rebuilds++587 b.Stats.Rebuilds++
561 if b.lay == nil {588 if b.lay == nil {
562 b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.pal, b.mods)589 b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.pal, b.mods)
@@ -608,6 +635,17 @@ func (b *Bar) fail(err error) {
608 635
609 func (b *Bar) shutdown() error {636 func (b *Bar) shutdown() error {
610 var errs []error637 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+ }
611 b.dropRenderer()649 b.dropRenderer()
612 b.face.Close()650 b.face.Close()
613 if b.node != nil {651 if b.node != nil {
@@ -628,7 +666,7 @@ const settleDelay = 60 * time.Millisecond
628 // The first report builds at once, because there is nothing on screen yet.666 // The first report builds at once, because there is nothing on screen yet.
629 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.667 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.
630 func (b *Bar) geometryChanged() {668 func (b *Bar) geometryChanged() {
631- if b.rend == nil {669+ if b.rend == nil && !b.fresh {
632 b.reconcile()670 b.reconcile()
633 return671 return
634 }672 }
M scripts/headless.sh +3 -2
@@ -1,19 +1,20 @@
11 #!/bin/bash
22 # Run neferbar against a nested headless NeferWL with a clean environment.
33 # usage: scripts/headless.sh <config> <seconds> [neferbar args...]
4-# env: SIZE=WxH (output size), SCALE=1.5 (output scale), OUT=dir (screenshots),
4+# env: TERMINAL and NEFERWL_STATE are passed through; SIZE=WxH (output size), SCALE=1.5 (output scale), OUT=dir (screenshots),
55 # DISPLAY_VAR=NEFERBAR_DISPLAY (pass the socket that way), NEFERBAR=binary, LOGLINES=n (compositor log lines to show).
66 set -eu
77 cfg=$1; secs=$2; shift 2
88 out=${OUT:-/tmp/neferbar-shots}
99 root=$(mktemp -d)
1010 mkdir -p "$root"/{run,config/neferwl,data,state} "$out"
11+if [ -n "${TERMINAL:-}" ]; then ln -s "$HOME/.config/$(basename "${TERMINAL%% *}")" "$root/config/$(basename "${TERMINAL%% *}")" 2>/dev/null; fi
1112 chmod 700 "$root/run"
1213 rm -f "$out"/*.png
1314 if [ -n "${SCALE:-}" ]; then
1415 printf 'output.HEADLESS-1.scale = %s\n' "$SCALE" >"$root/config/neferwl/config"
1516 fi
16-clean=(env -i PATH="$PATH" HOME="$root" XDG_RUNTIME_DIR="$root/run" XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" ${NEFERWL_STATE:+NEFERWL_STATE="$NEFERWL_STATE"})
17+clean=(env -i PATH="$PATH" HOME="$root" XDG_RUNTIME_DIR="$root/run" XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" ${NEFERWL_STATE:+NEFERWL_STATE="$NEFERWL_STATE"} ${TERMINAL:+TERMINAL="$TERMINAL"})
1718 "${clean[@]}" neferwl --backend=headless --no-terminal --no-xwayland --size "${SIZE:-800x200}" --timeout "$((secs + 6))s" --screenshot "$out" >"$root/neferwl.log" 2>&1 &
1819 comp=$!
1920 trap 'kill $comp 2>/dev/null || true; wait $comp 2>/dev/null || true' EXIT
@@ -1,19 +1,20 @@
1 #!/bin/bash1 #!/bin/bash
2 # Run neferbar against a nested headless NeferWL with a clean environment.2 # Run neferbar against a nested headless NeferWL with a clean environment.
3 # usage: scripts/headless.sh <config> <seconds> [neferbar args...]3 # usage: scripts/headless.sh <config> <seconds> [neferbar args...]
4-# env: SIZE=WxH (output size), SCALE=1.5 (output scale), OUT=dir (screenshots),4+# env: TERMINAL and NEFERWL_STATE are passed through; SIZE=WxH (output size), SCALE=1.5 (output scale), OUT=dir (screenshots),
5 # DISPLAY_VAR=NEFERBAR_DISPLAY (pass the socket that way), NEFERBAR=binary, LOGLINES=n (compositor log lines to show).5 # DISPLAY_VAR=NEFERBAR_DISPLAY (pass the socket that way), NEFERBAR=binary, LOGLINES=n (compositor log lines to show).
6 set -eu6 set -eu
7 cfg=$1; secs=$2; shift 27 cfg=$1; secs=$2; shift 2
8 out=${OUT:-/tmp/neferbar-shots}8 out=${OUT:-/tmp/neferbar-shots}
9 root=$(mktemp -d)9 root=$(mktemp -d)
10 mkdir -p "$root"/{run,config/neferwl,data,state} "$out"10 mkdir -p "$root"/{run,config/neferwl,data,state} "$out"
11+if [ -n "${TERMINAL:-}" ]; then ln -s "$HOME/.config/$(basename "${TERMINAL%% *}")" "$root/config/$(basename "${TERMINAL%% *}")" 2>/dev/null; fi
11 chmod 700 "$root/run"12 chmod 700 "$root/run"
12 rm -f "$out"/*.png13 rm -f "$out"/*.png
13 if [ -n "${SCALE:-}" ]; then14 if [ -n "${SCALE:-}" ]; then
14 printf 'output.HEADLESS-1.scale = %s\n' "$SCALE" >"$root/config/neferwl/config"15 printf 'output.HEADLESS-1.scale = %s\n' "$SCALE" >"$root/config/neferwl/config"
15 fi16 fi
16-clean=(env -i PATH="$PATH" HOME="$root" XDG_RUNTIME_DIR="$root/run" XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" ${NEFERWL_STATE:+NEFERWL_STATE="$NEFERWL_STATE"})17+clean=(env -i PATH="$PATH" HOME="$root" XDG_RUNTIME_DIR="$root/run" XDG_CONFIG_HOME="$root/config" XDG_DATA_HOME="$root/data" XDG_STATE_HOME="$root/state" ${NEFERWL_STATE:+NEFERWL_STATE="$NEFERWL_STATE"} ${TERMINAL:+TERMINAL="$TERMINAL"})
17 "${clean[@]}" neferwl --backend=headless --no-terminal --no-xwayland --size "${SIZE:-800x200}" --timeout "$((secs + 6))s" --screenshot "$out" >"$root/neferwl.log" 2>&1 &18 "${clean[@]}" neferwl --backend=headless --no-terminal --no-xwayland --size "${SIZE:-800x200}" --timeout "$((secs + 6))s" --screenshot "$out" >"$root/neferwl.log" 2>&1 &
18 comp=$!19 comp=$!
19 trap 'kill $comp 2>/dev/null || true; wait $comp 2>/dev/null || true' EXIT20 trap 'kill $comp 2>/dev/null || true; wait $comp 2>/dev/null || true' EXIT

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