detached bb8f0da

feat(bar): color the bar and its scripts from the terminal theme and watch the theme files

commit
bb8f0daf3ab80772a93ca7ad9334bc1d04625dfd
author
brice · · 2026-10-05 15:42 UTC
parent
b36cc68
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +151 -51
M internal/bar/bar.go +103 -24
@@ -15,6 +15,7 @@ import (
1515 "math"
1616 "os"
1717 "path/filepath"
18+ "slices"
1819 "strings"
1920 "syscall"
2021 "time"
@@ -22,11 +23,13 @@ import (
2223 "github.com/bnema/neferclient"
2324
2425 "git.bnema.dev/bnema/neferbar/internal/config"
26+ "git.bnema.dev/bnema/neferbar/internal/fswatch"
2527 "git.bnema.dev/bnema/neferbar/internal/glyph"
2628 "git.bnema.dev/bnema/neferbar/internal/gpu"
2729 "git.bnema.dev/bnema/neferbar/internal/layout"
2830 "git.bnema.dev/bnema/neferbar/internal/module"
2931 "git.bnema.dev/bnema/neferbar/internal/syncobj"
32+ "git.bnema.dev/bnema/neferbar/internal/theme"
3033 )
3134
3235 const acquireTimelineID = 1 << 32
@@ -80,6 +83,12 @@ type Bar struct {
8083 cfgPath string
8184 cfgCh chan config.Config
8285
86+ theme theme.Theme
87+ themeCh chan struct{}
88+ themeCancel context.CancelFunc
89+ pal [16][3]uint8
90+ env []string // theme colors for the scripts
91+
8392 // settling is true between a scale or size change and the moment both the
8493 // new scale and the new logical width have arrived. They come as separate
8594 // events, in either order; building a renderer from half of them presents a
@@ -98,12 +107,10 @@ type Bar struct {
98107 // New builds a bar from a validated config.
99108 // display names the Wayland socket; empty uses $WAYLAND_DISPLAY.
100109 func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
101- b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1)}
110+ b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),
111+ themeCh: make(chan struct{}, 1)}
102112 var err error
103- if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {
104- return nil, err
105- }
106- if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {
113+ if err = b.loadTheme(); err != nil {
107114 return nil, err
108115 }
109116 if b.fonts, err = findFonts(cfg.Bar.Font, log); err != nil {
@@ -115,6 +122,85 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
115122 return b, nil
116123 }
117124
125+// barColors picks the bar's own colors: the settings win, otherwise the theme
126+// foreground, and a background one step toward the foreground so the bar
127+// stands out from a terminal window of the same theme.
128+func barColors(cfg config.Config, th theme.Theme) (fg, bg [3]uint8) {
129+ fg, bg = th.Foreground, theme.Mix(th.Background, th.Foreground, 0.07)
130+ if c, err := config.ParseColor(cfg.Bar.Foreground); err == nil && cfg.Bar.Foreground != "" {
131+ fg = c
132+ }
133+ if c, err := config.ParseColor(cfg.Bar.Background); err == nil && cfg.Bar.Background != "" {
134+ bg = c
135+ }
136+ return fg, bg
137+}
138+
139+// loadTheme resolves bar.theme and applies it. When the theme cannot be read
140+// the built-in colors are used, and the problem is logged.
141+func (b *Bar) loadTheme() error {
142+ base := filepath.Dir(b.cfgPath)
143+ if b.cfgPath == "" {
144+ base = "."
145+ }
146+ th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
147+ if err != nil {
148+ b.log.Warn("theme not found; using the built-in colors", "theme", b.cfg.Bar.Theme, "err", err)
149+ } else {
150+ b.log.Info("theme", "source", th.Source)
151+ }
152+ b.setTheme(th)
153+ return nil
154+}
155+
156+// setTheme makes th the colors of the bar and of its scripts.
157+func (b *Bar) setTheme(th theme.Theme) {
158+ b.theme = th
159+ b.fg, b.bg = barColors(b.cfg, th)
160+ b.pal = th.Palette
161+ b.env = th.EnvVars(b.bg, b.fg)
162+ if b.lay != nil {
163+ b.lay.SetColors(b.fg, b.bg, b.pal)
164+ }
165+ b.dirty = true
166+ b.watchTheme()
167+}
168+
169+// watchTheme watches every file the theme was read from, so changing the
170+// terminal's theme changes the bar.
171+func (b *Bar) watchTheme() {
172+ if b.themeCancel != nil {
173+ b.themeCancel()
174+ }
175+ if b.runCtx == nil {
176+ return // Run has not started; it calls this again
177+ }
178+ ctx, cancel := context.WithCancel(b.runCtx)
179+ b.themeCancel = cancel
180+ for _, f := range b.theme.Files {
181+ go func() { _ = fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh) }()
182+ }
183+}
184+
185+// reloadTheme re-reads the theme after one of its files changed.
186+func (b *Bar) reloadTheme() {
187+ base := filepath.Dir(b.cfgPath)
188+ if b.cfgPath == "" {
189+ base = "."
190+ }
191+ th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
192+ if err != nil {
193+ b.log.Warn("theme reload failed; keeping the current colors", "err", err)
194+ return
195+ }
196+ if th.Equal(b.theme) {
197+ return
198+ }
199+ b.log.Info("theme changed", "source", th.Source)
200+ b.setTheme(th)
201+ b.syncModules(b.cfg.Module) // scripts get the new colors in their environment
202+}
203+
118204 // WatchConfig makes Run reload the config file at path when it changes. Call
119205 // it before Run.
120206 func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
@@ -122,6 +208,7 @@ func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
122208 // runner is one started module and how to stop it.
123209 type runner struct {
124210 cfg config.Module
211+ env []string
125212 m *module.Module
126213 cancel context.CancelFunc
127214 }
@@ -140,7 +227,7 @@ func (b *Bar) syncModules(want []config.Module) {
140227 for _, w := range want {
141228 if r, ok := old[w.Name]; ok {
142229 delete(old, w.Name)
143- if r.cfg == w {
230+ if r.cfg == w && slices.Equal(r.env, b.env) {
144231 next = append(next, r)
145232 continue
146233 }
@@ -148,8 +235,9 @@ func (b *Bar) syncModules(want []config.Module) {
148235 }
149236 ctx, cancel := context.WithCancel(b.runCtx)
150237 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
238+ m.Env = b.env
151239 go m.Run(ctx)
152- next = append(next, runner{cfg: w, m: m, cancel: cancel})
240+ next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
153241 }
154242 for _, r := range old {
155243 r.cancel()
@@ -167,16 +255,7 @@ func (b *Bar) syncModules(want []config.Module) {
167255 // applyConfig switches to next. Nothing changes if it cannot be applied.
168256 func (b *Bar) applyConfig(next config.Config) {
169257 old := b.cfg
170- fg, err := config.ParseColor(next.Bar.Foreground)
171- if err != nil {
172- b.log.Warn("config not applied", "err", err)
173- return
174- }
175- bg, err := config.ParseColor(next.Bar.Background)
176- if err != nil {
177- b.log.Warn("config not applied", "err", err)
178- return
179- }
258+ var err error
180259 fontChanged := next.Bar.Font != old.Bar.Font
181260 fonts := b.fonts
182261 if fontChanged {
@@ -191,13 +270,10 @@ func (b *Bar) applyConfig(next config.Config) {
191270 }
192271 b.cfg = next
193272 b.fonts = fonts
194- b.syncModules(next.Module)
195- if fg != b.fg || bg != b.bg {
196- b.fg, b.bg = fg, bg
197- if b.lay != nil {
198- b.lay.SetColors(fg, bg)
199- }
273+ if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed
274+ b.log.Warn("theme not applied", "err", err)
200275 }
276+ b.syncModules(next.Module)
201277 b.dirty = true
202278 if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
203279 b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
@@ -224,6 +300,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
224300 runCtx, cancel := context.WithCancel(ctx)
225301 defer cancel()
226302 b.runCtx = runCtx
303+ b.watchTheme()
227304 b.syncModules(b.cfg.Module)
228305 if b.cfgPath != "" {
229306 go func() {
@@ -244,6 +321,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
244321 b.Stats.ModuleWake++
245322 case next := <-b.cfgCh:
246323 b.applyConfig(next)
324+ case <-b.themeCh:
325+ b.reloadTheme()
247326 case <-b.settleC:
248327 b.settleC, b.settling = nil, false
249328 b.reconcile()
@@ -480,7 +559,7 @@ func (b *Bar) reconcile() {
480559 b.rend, b.mappedW = rend, w
481560 b.Stats.Rebuilds++
482561 if b.lay == nil {
483- b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.mods)
562+ b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.pal, b.mods)
484563 } else {
485564 b.lay.Resize(rend.Cols())
486565 }
@@ -15,6 +15,7 @@ import (
15 "math"15 "math"
16 "os"16 "os"
17 "path/filepath"17 "path/filepath"
18+ "slices"
18 "strings"19 "strings"
19 "syscall"20 "syscall"
20 "time"21 "time"
@@ -22,11 +23,13 @@ import (
22 "github.com/bnema/neferclient"23 "github.com/bnema/neferclient"
23 24
24 "git.bnema.dev/bnema/neferbar/internal/config"25 "git.bnema.dev/bnema/neferbar/internal/config"
26+ "git.bnema.dev/bnema/neferbar/internal/fswatch"
25 "git.bnema.dev/bnema/neferbar/internal/glyph"27 "git.bnema.dev/bnema/neferbar/internal/glyph"
26 "git.bnema.dev/bnema/neferbar/internal/gpu"28 "git.bnema.dev/bnema/neferbar/internal/gpu"
27 "git.bnema.dev/bnema/neferbar/internal/layout"29 "git.bnema.dev/bnema/neferbar/internal/layout"
28 "git.bnema.dev/bnema/neferbar/internal/module"30 "git.bnema.dev/bnema/neferbar/internal/module"
29 "git.bnema.dev/bnema/neferbar/internal/syncobj"31 "git.bnema.dev/bnema/neferbar/internal/syncobj"
32+ "git.bnema.dev/bnema/neferbar/internal/theme"
30 )33 )
31 34
32 const acquireTimelineID = 1 << 3235 const acquireTimelineID = 1 << 32
@@ -80,6 +83,12 @@ type Bar struct {
80 cfgPath string83 cfgPath string
81 cfgCh chan config.Config84 cfgCh chan config.Config
82 85
86+ theme theme.Theme
87+ themeCh chan struct{}
88+ themeCancel context.CancelFunc
89+ pal [16][3]uint8
90+ env []string // theme colors for the scripts
91+
83 // settling is true between a scale or size change and the moment both the92 // settling is true between a scale or size change and the moment both the
84 // new scale and the new logical width have arrived. They come as separate93 // new scale and the new logical width have arrived. They come as separate
85 // events, in either order; building a renderer from half of them presents a94 // events, in either order; building a renderer from half of them presents a
@@ -98,12 +107,10 @@ type Bar struct {
98 // New builds a bar from a validated config.107 // New builds a bar from a validated config.
99 // display names the Wayland socket; empty uses $WAYLAND_DISPLAY.108 // display names the Wayland socket; empty uses $WAYLAND_DISPLAY.
100 func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {109 func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
101- b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1)}110+ b := &Bar{cfg: cfg, log: log, display: display, modCh: make(chan struct{}, 1), cfgCh: make(chan config.Config, 1),
111+ themeCh: make(chan struct{}, 1)}
102 var err error112 var err error
103- if b.fg, err = config.ParseColor(cfg.Bar.Foreground); err != nil {113+ if err = b.loadTheme(); err != nil {
104- return nil, err
105- }
106- if b.bg, err = config.ParseColor(cfg.Bar.Background); err != nil {
107 return nil, err114 return nil, err
108 }115 }
109 if b.fonts, err = findFonts(cfg.Bar.Font, log); err != nil {116 if b.fonts, err = findFonts(cfg.Bar.Font, log); err != nil {
@@ -115,6 +122,85 @@ func New(cfg config.Config, log *slog.Logger, display string) (*Bar, error) {
115 return b, nil122 return b, nil
116 }123 }
117 124
125+// barColors picks the bar's own colors: the settings win, otherwise the theme
126+// foreground, and a background one step toward the foreground so the bar
127+// stands out from a terminal window of the same theme.
128+func barColors(cfg config.Config, th theme.Theme) (fg, bg [3]uint8) {
129+ fg, bg = th.Foreground, theme.Mix(th.Background, th.Foreground, 0.07)
130+ if c, err := config.ParseColor(cfg.Bar.Foreground); err == nil && cfg.Bar.Foreground != "" {
131+ fg = c
132+ }
133+ if c, err := config.ParseColor(cfg.Bar.Background); err == nil && cfg.Bar.Background != "" {
134+ bg = c
135+ }
136+ return fg, bg
137+}
138+
139+// loadTheme resolves bar.theme and applies it. When the theme cannot be read
140+// the built-in colors are used, and the problem is logged.
141+func (b *Bar) loadTheme() error {
142+ base := filepath.Dir(b.cfgPath)
143+ if b.cfgPath == "" {
144+ base = "."
145+ }
146+ th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
147+ if err != nil {
148+ b.log.Warn("theme not found; using the built-in colors", "theme", b.cfg.Bar.Theme, "err", err)
149+ } else {
150+ b.log.Info("theme", "source", th.Source)
151+ }
152+ b.setTheme(th)
153+ return nil
154+}
155+
156+// setTheme makes th the colors of the bar and of its scripts.
157+func (b *Bar) setTheme(th theme.Theme) {
158+ b.theme = th
159+ b.fg, b.bg = barColors(b.cfg, th)
160+ b.pal = th.Palette
161+ b.env = th.EnvVars(b.bg, b.fg)
162+ if b.lay != nil {
163+ b.lay.SetColors(b.fg, b.bg, b.pal)
164+ }
165+ b.dirty = true
166+ b.watchTheme()
167+}
168+
169+// watchTheme watches every file the theme was read from, so changing the
170+// terminal's theme changes the bar.
171+func (b *Bar) watchTheme() {
172+ if b.themeCancel != nil {
173+ b.themeCancel()
174+ }
175+ if b.runCtx == nil {
176+ return // Run has not started; it calls this again
177+ }
178+ ctx, cancel := context.WithCancel(b.runCtx)
179+ b.themeCancel = cancel
180+ for _, f := range b.theme.Files {
181+ go func() { _ = fswatch.Watch(ctx, f, 150*time.Millisecond, b.themeCh) }()
182+ }
183+}
184+
185+// reloadTheme re-reads the theme after one of its files changed.
186+func (b *Bar) reloadTheme() {
187+ base := filepath.Dir(b.cfgPath)
188+ if b.cfgPath == "" {
189+ base = "."
190+ }
191+ th, err := theme.Resolve(b.cfg.Bar.Theme, base, theme.SystemEnv())
192+ if err != nil {
193+ b.log.Warn("theme reload failed; keeping the current colors", "err", err)
194+ return
195+ }
196+ if th.Equal(b.theme) {
197+ return
198+ }
199+ b.log.Info("theme changed", "source", th.Source)
200+ b.setTheme(th)
201+ b.syncModules(b.cfg.Module) // scripts get the new colors in their environment
202+}
203+
118 // WatchConfig makes Run reload the config file at path when it changes. Call204 // WatchConfig makes Run reload the config file at path when it changes. Call
119 // it before Run.205 // it before Run.
120 func (b *Bar) WatchConfig(path string) { b.cfgPath = path }206 func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
@@ -122,6 +208,7 @@ func (b *Bar) WatchConfig(path string) { b.cfgPath = path }
122 // runner is one started module and how to stop it.208 // runner is one started module and how to stop it.
123 type runner struct {209 type runner struct {
124 cfg config.Module210 cfg config.Module
211+ env []string
125 m *module.Module212 m *module.Module
126 cancel context.CancelFunc213 cancel context.CancelFunc
127 }214 }
@@ -140,7 +227,7 @@ func (b *Bar) syncModules(want []config.Module) {
140 for _, w := range want {227 for _, w := range want {
141 if r, ok := old[w.Name]; ok {228 if r, ok := old[w.Name]; ok {
142 delete(old, w.Name)229 delete(old, w.Name)
143- if r.cfg == w {230+ if r.cfg == w && slices.Equal(r.env, b.env) {
144 next = append(next, r)231 next = append(next, r)
145 continue232 continue
146 }233 }
@@ -148,8 +235,9 @@ func (b *Bar) syncModules(want []config.Module) {
148 }235 }
149 ctx, cancel := context.WithCancel(b.runCtx)236 ctx, cancel := context.WithCancel(b.runCtx)
150 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)237 m := module.New(w.Name, zones[w.Zone], w.Exec, b.modCh, b.log)
238+ m.Env = b.env
151 go m.Run(ctx)239 go m.Run(ctx)
152- next = append(next, runner{cfg: w, m: m, cancel: cancel})240+ next = append(next, runner{cfg: w, env: b.env, m: m, cancel: cancel})
153 }241 }
154 for _, r := range old {242 for _, r := range old {
155 r.cancel()243 r.cancel()
@@ -167,16 +255,7 @@ func (b *Bar) syncModules(want []config.Module) {
167 // applyConfig switches to next. Nothing changes if it cannot be applied.255 // applyConfig switches to next. Nothing changes if it cannot be applied.
168 func (b *Bar) applyConfig(next config.Config) {256 func (b *Bar) applyConfig(next config.Config) {
169 old := b.cfg257 old := b.cfg
170- fg, err := config.ParseColor(next.Bar.Foreground)258+ var err error
171- if err != nil {
172- b.log.Warn("config not applied", "err", err)
173- return
174- }
175- bg, err := config.ParseColor(next.Bar.Background)
176- if err != nil {
177- b.log.Warn("config not applied", "err", err)
178- return
179- }
180 fontChanged := next.Bar.Font != old.Bar.Font259 fontChanged := next.Bar.Font != old.Bar.Font
181 fonts := b.fonts260 fonts := b.fonts
182 if fontChanged {261 if fontChanged {
@@ -191,13 +270,10 @@ func (b *Bar) applyConfig(next config.Config) {
191 }270 }
192 b.cfg = next271 b.cfg = next
193 b.fonts = fonts272 b.fonts = fonts
194- b.syncModules(next.Module)273+ if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed
195- if fg != b.fg || bg != b.bg {274+ b.log.Warn("theme not applied", "err", err)
196- b.fg, b.bg = fg, bg
197- if b.lay != nil {
198- b.lay.SetColors(fg, bg)
199- }
200 }275 }
276+ b.syncModules(next.Module)
201 b.dirty = true277 b.dirty = true
202 if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {278 if fontChanged || next.Bar.Size != old.Bar.Size || next.Bar.Scale != old.Bar.Scale {
203 b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed279 b.facePx = -1 // reconcile rebuilds the font, and the surface if its height changed
@@ -224,6 +300,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
224 runCtx, cancel := context.WithCancel(ctx)300 runCtx, cancel := context.WithCancel(ctx)
225 defer cancel()301 defer cancel()
226 b.runCtx = runCtx302 b.runCtx = runCtx
303+ b.watchTheme()
227 b.syncModules(b.cfg.Module)304 b.syncModules(b.cfg.Module)
228 if b.cfgPath != "" {305 if b.cfgPath != "" {
229 go func() {306 go func() {
@@ -244,6 +321,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
244 b.Stats.ModuleWake++321 b.Stats.ModuleWake++
245 case next := <-b.cfgCh:322 case next := <-b.cfgCh:
246 b.applyConfig(next)323 b.applyConfig(next)
324+ case <-b.themeCh:
325+ b.reloadTheme()
247 case <-b.settleC:326 case <-b.settleC:
248 b.settleC, b.settling = nil, false327 b.settleC, b.settling = nil, false
249 b.reconcile()328 b.reconcile()
@@ -480,7 +559,7 @@ func (b *Bar) reconcile() {
480 b.rend, b.mappedW = rend, w559 b.rend, b.mappedW = rend, w
481 b.Stats.Rebuilds++560 b.Stats.Rebuilds++
482 if b.lay == nil {561 if b.lay == nil {
483- b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.mods)562+ b.lay = layout.New(rend.Cols(), b.fg, b.bg, b.pal, b.mods)
484 } else {563 } else {
485 b.lay.Resize(rend.Cols())564 b.lay.Resize(rend.Cols())
486 }565 }
M internal/layout/layout.go +15 -13
@@ -24,13 +24,14 @@ type Source struct {
2424 type Layout struct {
2525 cols int
2626 fg, bg [3]uint8
27+ pal [16][3]uint8
2728 sources []*Source
2829 row []gpu.Cell
2930 }
3031
3132 // New builds a layout for a row of cols cells.
32-func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {
33- l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}
33+func New(cols int, fg, bg [3]uint8, pal [16][3]uint8, mods []*module.Module) *Layout {
34+ l := &Layout{cols: cols, fg: fg, bg: bg, pal: pal, row: make([]gpu.Cell, cols)}
3435 l.SetModules(mods)
3536 return l
3637 }
@@ -65,14 +66,15 @@ func (l *Layout) SetModules(mods []*module.Module) {
6566 }
6667 }
6768
68-// SetColors changes the default colors and reparses every module's last frame.
69-func (l *Layout) SetColors(fg, bg [3]uint8) {
70- l.fg, l.bg = fg, bg
69+// SetColors changes the default colors and the 16-color palette, and reparses
70+// every module's last frame.
71+func (l *Layout) SetColors(fg, bg [3]uint8, pal [16][3]uint8) {
72+ l.fg, l.bg, l.pal = fg, bg, pal
7173 for _, s := range l.sources {
7274 if s.failed {
7375 s.cells = l.marker(s.cells[:0], s.M.Name)
7476 } else if len(s.frame) > 0 {
75- s.parse(fg, bg)
77+ s.parse(fg, bg, l.pal)
7678 }
7779 }
7880 }
@@ -88,7 +90,7 @@ func (l *Layout) Resize(cols int) {
8890 if s.failed {
8991 s.cells = l.marker(s.cells, s.M.Name)
9092 } else if len(s.frame) > 0 {
91- s.parse(l.fg, l.bg)
93+ s.parse(l.fg, l.bg, l.pal)
9294 }
9395 }
9496 }
@@ -109,7 +111,7 @@ func (l *Layout) Update() bool {
109111 s.cells = l.marker(s.cells[:0], s.M.Name)
110112 continue
111113 }
112- s.parse(l.fg, l.bg)
114+ s.parse(l.fg, l.bg, l.pal)
113115 }
114116 return changed
115117 }
@@ -127,7 +129,7 @@ func (l *Layout) marker(dst []gpu.Cell, name string) []gpu.Cell {
127129 return dst
128130 }
129131
130-func (s *Source) parse(fg, bg [3]uint8) {
132+func (s *Source) parse(fg, bg [3]uint8, pal [16][3]uint8) {
131133 s.input = append(s.input[:0], "\x1b[0m\r\x1b[2K"...)
132134 s.input = append(s.input, s.frame...)
133135 s.screen.Write(s.input)
@@ -144,24 +146,24 @@ func (s *Source) parse(fg, bg [3]uint8) {
144146 }
145147 s.cells = s.cells[:0]
146148 for x := 0; x < end; x++ {
147- s.cells = append(s.cells, convert(s.screen.Cell(x, 0), fg, bg))
149+ s.cells = append(s.cells, convert(s.screen.Cell(x, 0), fg, bg, &pal))
148150 }
149151 }
150152
151-func convert(c vt.Cell, defFG, defBG [3]uint8) gpu.Cell {
153+func convert(c vt.Cell, defFG, defBG [3]uint8, pal *[16][3]uint8) gpu.Cell {
152154 st := &c.Style
153155 fg, bg := defFG, defBG
154156 switch {
155157 case st.HasForegroundRGB:
156158 fg = [3]uint8{st.ForegroundRGB.R, st.ForegroundRGB.G, st.ForegroundRGB.B}
157159 case st.Foreground >= 0:
158- fg = palette(st.Foreground)
160+ fg = palette(st.Foreground, pal)
159161 }
160162 switch {
161163 case st.HasBackgroundRGB:
162164 bg = [3]uint8{st.BackgroundRGB.R, st.BackgroundRGB.G, st.BackgroundRGB.B}
163165 case st.Background >= 0:
164- bg = palette(st.Background)
166+ bg = palette(st.Background, pal)
165167 }
166168 if st.Inverse {
167169 fg, bg = bg, fg
@@ -24,13 +24,14 @@ type Source struct {
24 type Layout struct {24 type Layout struct {
25 cols int25 cols int
26 fg, bg [3]uint826 fg, bg [3]uint8
27+ pal [16][3]uint8
27 sources []*Source28 sources []*Source
28 row []gpu.Cell29 row []gpu.Cell
29 }30 }
30 31
31 // New builds a layout for a row of cols cells.32 // New builds a layout for a row of cols cells.
32-func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {33+func New(cols int, fg, bg [3]uint8, pal [16][3]uint8, mods []*module.Module) *Layout {
33- l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}34+ l := &Layout{cols: cols, fg: fg, bg: bg, pal: pal, row: make([]gpu.Cell, cols)}
34 l.SetModules(mods)35 l.SetModules(mods)
35 return l36 return l
36 }37 }
@@ -65,14 +66,15 @@ func (l *Layout) SetModules(mods []*module.Module) {
65 }66 }
66 }67 }
67 68
68-// SetColors changes the default colors and reparses every module's last frame.69+// SetColors changes the default colors and the 16-color palette, and reparses
69-func (l *Layout) SetColors(fg, bg [3]uint8) {70+// every module's last frame.
70- l.fg, l.bg = fg, bg71+func (l *Layout) SetColors(fg, bg [3]uint8, pal [16][3]uint8) {
72+ l.fg, l.bg, l.pal = fg, bg, pal
71 for _, s := range l.sources {73 for _, s := range l.sources {
72 if s.failed {74 if s.failed {
73 s.cells = l.marker(s.cells[:0], s.M.Name)75 s.cells = l.marker(s.cells[:0], s.M.Name)
74 } else if len(s.frame) > 0 {76 } else if len(s.frame) > 0 {
75- s.parse(fg, bg)77+ s.parse(fg, bg, l.pal)
76 }78 }
77 }79 }
78 }80 }
@@ -88,7 +90,7 @@ func (l *Layout) Resize(cols int) {
88 if s.failed {90 if s.failed {
89 s.cells = l.marker(s.cells, s.M.Name)91 s.cells = l.marker(s.cells, s.M.Name)
90 } else if len(s.frame) > 0 {92 } else if len(s.frame) > 0 {
91- s.parse(l.fg, l.bg)93+ s.parse(l.fg, l.bg, l.pal)
92 }94 }
93 }95 }
94 }96 }
@@ -109,7 +111,7 @@ func (l *Layout) Update() bool {
109 s.cells = l.marker(s.cells[:0], s.M.Name)111 s.cells = l.marker(s.cells[:0], s.M.Name)
110 continue112 continue
111 }113 }
112- s.parse(l.fg, l.bg)114+ s.parse(l.fg, l.bg, l.pal)
113 }115 }
114 return changed116 return changed
115 }117 }
@@ -127,7 +129,7 @@ func (l *Layout) marker(dst []gpu.Cell, name string) []gpu.Cell {
127 return dst129 return dst
128 }130 }
129 131
130-func (s *Source) parse(fg, bg [3]uint8) {132+func (s *Source) parse(fg, bg [3]uint8, pal [16][3]uint8) {
131 s.input = append(s.input[:0], "\x1b[0m\r\x1b[2K"...)133 s.input = append(s.input[:0], "\x1b[0m\r\x1b[2K"...)
132 s.input = append(s.input, s.frame...)134 s.input = append(s.input, s.frame...)
133 s.screen.Write(s.input)135 s.screen.Write(s.input)
@@ -144,24 +146,24 @@ func (s *Source) parse(fg, bg [3]uint8) {
144 }146 }
145 s.cells = s.cells[:0]147 s.cells = s.cells[:0]
146 for x := 0; x < end; x++ {148 for x := 0; x < end; x++ {
147- s.cells = append(s.cells, convert(s.screen.Cell(x, 0), fg, bg))149+ s.cells = append(s.cells, convert(s.screen.Cell(x, 0), fg, bg, &pal))
148 }150 }
149 }151 }
150 152
151-func convert(c vt.Cell, defFG, defBG [3]uint8) gpu.Cell {153+func convert(c vt.Cell, defFG, defBG [3]uint8, pal *[16][3]uint8) gpu.Cell {
152 st := &c.Style154 st := &c.Style
153 fg, bg := defFG, defBG155 fg, bg := defFG, defBG
154 switch {156 switch {
155 case st.HasForegroundRGB:157 case st.HasForegroundRGB:
156 fg = [3]uint8{st.ForegroundRGB.R, st.ForegroundRGB.G, st.ForegroundRGB.B}158 fg = [3]uint8{st.ForegroundRGB.R, st.ForegroundRGB.G, st.ForegroundRGB.B}
157 case st.Foreground >= 0:159 case st.Foreground >= 0:
158- fg = palette(st.Foreground)160+ fg = palette(st.Foreground, pal)
159 }161 }
160 switch {162 switch {
161 case st.HasBackgroundRGB:163 case st.HasBackgroundRGB:
162 bg = [3]uint8{st.BackgroundRGB.R, st.BackgroundRGB.G, st.BackgroundRGB.B}164 bg = [3]uint8{st.BackgroundRGB.R, st.BackgroundRGB.G, st.BackgroundRGB.B}
163 case st.Background >= 0:165 case st.Background >= 0:
164- bg = palette(st.Background)166+ bg = palette(st.Background, pal)
165 }167 }
166 if st.Inverse {168 if st.Inverse {
167 fg, bg = bg, fg169 fg, bg = bg, fg
M internal/layout/layout_test.go +25 -1
@@ -10,6 +10,8 @@ import (
1010 "git.bnema.dev/bnema/neferbar/internal/racecheck"
1111 )
1212
13+var testPalette = [16][3]uint8{{1, 1, 1}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, {0, 0, 255}}
14+
1315 func newTestLayout(cols int) (*Layout, []*module.Module) {
1416 wake := make(chan struct{}, 1)
1517 log := slog.New(slog.DiscardHandler)
@@ -18,7 +20,7 @@ func newTestLayout(cols int) (*Layout, []*module.Module) {
1820 module.New("c", module.Center, "true", wake, log),
1921 module.New("r", module.Right, "true", wake, log),
2022 }
21- return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, mods), mods
23+ return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, testPalette, mods), mods
2224 }
2325
2426 func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }
@@ -139,3 +141,25 @@ func TestTextStyles(t *testing.T) {
139141 }
140142 }
141143 }
144+
145+func TestAnsiColorsComeFromThePalette(t *testing.T) {
146+ l, m := newTestLayout(6)
147+ publish(m[0], "\x1b[31mr\x1b[44mb\x1b[0m\x1b[38;5;2mg")
148+ l.Update()
149+ row := l.Compose()
150+ if row[0].FG != testPalette[1] {
151+ t.Errorf("\\e[31m = %v, want palette[1] %v", row[0].FG, testPalette[1])
152+ }
153+ if row[1].BG != testPalette[4] {
154+ t.Errorf("\\e[44m background = %v, want palette[4] %v", row[1].BG, testPalette[4])
155+ }
156+ if row[2].FG != testPalette[2] {
157+ t.Errorf("\\e[38;5;2m = %v, want palette[2] %v", row[2].FG, testPalette[2])
158+ }
159+ pal := testPalette
160+ pal[1] = [3]uint8{9, 9, 9}
161+ l.SetColors(l.fg, l.bg, pal)
162+ if got := l.Compose()[0].FG; got != pal[1] {
163+ t.Errorf("after SetColors, \\e[31m = %v, want %v", got, pal[1])
164+ }
165+}
@@ -10,6 +10,8 @@ import (
10 "git.bnema.dev/bnema/neferbar/internal/racecheck"10 "git.bnema.dev/bnema/neferbar/internal/racecheck"
11 )11 )
12 12
13+var testPalette = [16][3]uint8{{1, 1, 1}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, {0, 0, 255}}
14+
13 func newTestLayout(cols int) (*Layout, []*module.Module) {15 func newTestLayout(cols int) (*Layout, []*module.Module) {
14 wake := make(chan struct{}, 1)16 wake := make(chan struct{}, 1)
15 log := slog.New(slog.DiscardHandler)17 log := slog.New(slog.DiscardHandler)
@@ -18,7 +20,7 @@ func newTestLayout(cols int) (*Layout, []*module.Module) {
18 module.New("c", module.Center, "true", wake, log),20 module.New("c", module.Center, "true", wake, log),
19 module.New("r", module.Right, "true", wake, log),21 module.New("r", module.Right, "true", wake, log),
20 }22 }
21- return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, mods), mods23+ return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, testPalette, mods), mods
22 }24 }
23 25
24 func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }26 func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }
@@ -139,3 +141,25 @@ func TestTextStyles(t *testing.T) {
139 }141 }
140 }142 }
141 }143 }
144+
145+func TestAnsiColorsComeFromThePalette(t *testing.T) {
146+ l, m := newTestLayout(6)
147+ publish(m[0], "\x1b[31mr\x1b[44mb\x1b[0m\x1b[38;5;2mg")
148+ l.Update()
149+ row := l.Compose()
150+ if row[0].FG != testPalette[1] {
151+ t.Errorf("\\e[31m = %v, want palette[1] %v", row[0].FG, testPalette[1])
152+ }
153+ if row[1].BG != testPalette[4] {
154+ t.Errorf("\\e[44m background = %v, want palette[4] %v", row[1].BG, testPalette[4])
155+ }
156+ if row[2].FG != testPalette[2] {
157+ t.Errorf("\\e[38;5;2m = %v, want palette[2] %v", row[2].FG, testPalette[2])
158+ }
159+ pal := testPalette
160+ pal[1] = [3]uint8{9, 9, 9}
161+ l.SetColors(l.fg, l.bg, pal)
162+ if got := l.Compose()[0].FG; got != pal[1] {
163+ t.Errorf("after SetColors, \\e[31m = %v, want %v", got, pal[1])
164+ }
165+}
M internal/layout/palette.go +4 -11
@@ -1,22 +1,15 @@
11 package layout
22
3-// base16 is the 16 standard ANSI colors.
4-var base16 = [16][3]uint8{
5- {0x45, 0x47, 0x5a}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
6- {0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xba, 0xc2, 0xde},
7- {0x58, 0x5b, 0x70}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
8- {0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xa6, 0xad, 0xc8},
9-}
10-
113 var cube = [6]uint8{0, 95, 135, 175, 215, 255}
124
13-// palette maps an xterm 256-color index to RGB.
14-func palette(i int) [3]uint8 {
5+// palette maps an xterm 256-color index to RGB. The first 16 come from the
6+// theme; the color cube and the gray ramp are fixed, as in any terminal.
7+func palette(i int, base *[16][3]uint8) [3]uint8 {
158 switch {
169 case i < 0:
1710 return [3]uint8{}
1811 case i < 16:
19- return base16[i]
12+ return base[i]
2013 case i < 232:
2114 i -= 16
2215 return [3]uint8{cube[i/36], cube[i/6%6], cube[i%6]}
@@ -1,22 +1,15 @@
1 package layout1 package layout
2 2
3-// base16 is the 16 standard ANSI colors.
4-var base16 = [16][3]uint8{
5- {0x45, 0x47, 0x5a}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
6- {0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xba, 0xc2, 0xde},
7- {0x58, 0x5b, 0x70}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
8- {0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xa6, 0xad, 0xc8},
9-}
10-
11 var cube = [6]uint8{0, 95, 135, 175, 215, 255}3 var cube = [6]uint8{0, 95, 135, 175, 215, 255}
12 4
13-// palette maps an xterm 256-color index to RGB.5+// palette maps an xterm 256-color index to RGB. The first 16 come from the
14-func palette(i int) [3]uint8 {6+// theme; the color cube and the gray ramp are fixed, as in any terminal.
7+func palette(i int, base *[16][3]uint8) [3]uint8 {
15 switch {8 switch {
16 case i < 0:9 case i < 0:
17 return [3]uint8{}10 return [3]uint8{}
18 case i < 16:11 case i < 16:
19- return base16[i]12+ return base[i]
20 case i < 232:13 case i < 232:
21 i -= 1614 i -= 16
22 return [3]uint8{cube[i/36], cube[i/6%6], cube[i%6]}15 return [3]uint8{cube[i/36], cube[i/6%6], cube[i%6]}
M internal/module/module.go +3 -1
@@ -42,6 +42,8 @@ type Module struct {
4242 Name string
4343 Zone Zone
4444 Exec string
45+ // Env is added to the script's environment, as KEY=value entries.
46+ Env []string
4547
4648 mu sync.Mutex
4749 pending []byte // latest complete frame
@@ -126,7 +128,7 @@ func (m *Module) runOnce(ctx context.Context) error {
126128 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
127129 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
128130 cmd.WaitDelay = 2 * time.Second
129- cmd.Env = append(os.Environ(), "NEFERBAR_MODULE="+m.Name)
131+ cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
130132 out, err := cmd.StdoutPipe()
131133 if err != nil {
132134 return err
@@ -42,6 +42,8 @@ type Module struct {
42 Name string42 Name string
43 Zone Zone43 Zone Zone
44 Exec string44 Exec string
45+ // Env is added to the script's environment, as KEY=value entries.
46+ Env []string
45 47
46 mu sync.Mutex48 mu sync.Mutex
47 pending []byte // latest complete frame49 pending []byte // latest complete frame
@@ -126,7 +128,7 @@ func (m *Module) runOnce(ctx context.Context) error {
126 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}128 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
127 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }129 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
128 cmd.WaitDelay = 2 * time.Second130 cmd.WaitDelay = 2 * time.Second
129- cmd.Env = append(os.Environ(), "NEFERBAR_MODULE="+m.Name)131+ cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
130 out, err := cmd.StdoutPipe()132 out, err := cmd.StdoutPipe()
131 if err != nil {133 if err != nil {
132 return err134 return err
M scripts/headless.sh +1 -1
@@ -13,7 +13,7 @@ rm -f "$out"/*.png
1313 if [ -n "${SCALE:-}" ]; then
1414 printf 'output.HEADLESS-1.scale = %s\n' "$SCALE" >"$root/config/neferwl/config"
1515 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")
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"})
1717 "${clean[@]}" neferwl --backend=headless --no-terminal --no-xwayland --size "${SIZE:-800x200}" --timeout "$((secs + 6))s" --screenshot "$out" >"$root/neferwl.log" 2>&1 &
1818 comp=$!
1919 trap 'kill $comp 2>/dev/null || true; wait $comp 2>/dev/null || true' EXIT
@@ -13,7 +13,7 @@ rm -f "$out"/*.png
13 if [ -n "${SCALE:-}" ]; then13 if [ -n "${SCALE:-}" ]; then
14 printf 'output.HEADLESS-1.scale = %s\n' "$SCALE" >"$root/config/neferwl/config"14 printf 'output.HEADLESS-1.scale = %s\n' "$SCALE" >"$root/config/neferwl/config"
15 fi15 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")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[@]}" neferwl --backend=headless --no-terminal --no-xwayland --size "${SIZE:-800x200}" --timeout "$((secs + 6))s" --screenshot "$out" >"$root/neferwl.log" 2>&1 &17 "${clean[@]}" neferwl --backend=headless --no-terminal --no-xwayland --size "${SIZE:-800x200}" --timeout "$((secs + 6))s" --screenshot "$out" >"$root/neferwl.log" 2>&1 &
18 comp=$!18 comp=$!
19 trap 'kill $comp 2>/dev/null || true; wait $comp 2>/dev/null || true' EXIT19 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