detached 063f71c

feat(modules): run ANSI script modules and compose them into left, center and right zones

commit
063f71ca4e3c1f09ca66bf5d30550c663b031733
author
brice · · 2026-10-05 14:08 UTC
parent
14a7b2d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +792 -0
A internal/config/config.go +117 -0
@@ -0,0 +1,117 @@
1+// Package config loads the bar's TOML configuration.
2+package config
3+
4+import (
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/BurntSushi/toml"
10+)
11+
12+// Bar holds the global bar settings.
13+type Bar struct {
14+ Font string `toml:"font"`
15+ Size float64 `toml:"size"` // font size in logical pixels
16+ // Scale multiplies the monitor scale (1.0 follows the monitor).
17+ Scale float64 `toml:"scale"`
18+ // Output is the wl_output name; empty lets the compositor choose.
19+ Output string `toml:"output"`
20+ // Background and Foreground are #rrggbb.
21+ Background string `toml:"background"`
22+ Foreground string `toml:"foreground"`
23+}
24+
25+// Module is one script.
26+type Module struct {
27+ Name string `toml:"name"`
28+ Zone string `toml:"zone"` // left | center | right
29+ Exec string `toml:"exec"`
30+}
31+
32+// Config is the whole file.
33+type Config struct {
34+ Bar Bar `toml:"bar"`
35+ Module []Module `toml:"module"`
36+}
37+
38+// Default returns the built-in settings.
39+func Default() Config {
40+ return Config{Bar: Bar{
41+ Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,
42+ Background: "#1e1e2e", Foreground: "#cdd6f4",
43+ }}
44+}
45+
46+// DefaultPath is $XDG_CONFIG_HOME/neferbar/config.toml.
47+func DefaultPath() string {
48+ dir := os.Getenv("XDG_CONFIG_HOME")
49+ if dir == "" {
50+ home, _ := os.UserHomeDir()
51+ dir = filepath.Join(home, ".config")
52+ }
53+ return filepath.Join(dir, "neferbar", "config.toml")
54+}
55+
56+// Load reads path over the defaults. A missing file at the default path is
57+// not an error.
58+func Load(path string, explicit bool) (Config, error) {
59+ c := Default()
60+ md, err := toml.DecodeFile(path, &c)
61+ if err != nil {
62+ if !explicit && os.IsNotExist(err) {
63+ return c, nil
64+ }
65+ return c, fmt.Errorf("config: %w", err)
66+ }
67+ if u := md.Undecoded(); len(u) > 0 {
68+ return c, fmt.Errorf("config: unknown key %s", u[0])
69+ }
70+ return c, c.Validate()
71+}
72+
73+// Validate checks every field.
74+func (c *Config) Validate() error {
75+ b := &c.Bar
76+ if b.Font == "" {
77+ return fmt.Errorf("config: bar.font is empty")
78+ }
79+ if b.Size < 6 || b.Size > 128 {
80+ return fmt.Errorf("config: bar.size %v out of range 6..128", b.Size)
81+ }
82+ if b.Scale < 0.5 || b.Scale > 8 {
83+ return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale)
84+ }
85+ for _, col := range []string{b.Background, b.Foreground} {
86+ if _, err := ParseColor(col); err != nil {
87+ return err
88+ }
89+ }
90+ for i := range c.Module {
91+ m := &c.Module[i]
92+ if m.Exec == "" {
93+ return fmt.Errorf("config: module %d has no exec", i+1)
94+ }
95+ if m.Name == "" {
96+ m.Name = fmt.Sprintf("module%d", i+1)
97+ }
98+ switch m.Zone {
99+ case "left", "center", "right":
100+ default:
101+ return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)
102+ }
103+ }
104+ return nil
105+}
106+
107+// ParseColor parses #rrggbb.
108+func ParseColor(s string) ([3]uint8, error) {
109+ var r, g, b uint8
110+ if len(s) != 7 || s[0] != '#' {
111+ return [3]uint8{}, fmt.Errorf("config: color %q must be #rrggbb", s)
112+ }
113+ if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
114+ return [3]uint8{}, fmt.Errorf("config: color %q must be #rrggbb", s)
115+ }
116+ return [3]uint8{r, g, b}, nil
117+}
@@ -0,0 +1,117 @@
1+// Package config loads the bar's TOML configuration.
2+package config
3+
4+import (
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/BurntSushi/toml"
10+)
11+
12+// Bar holds the global bar settings.
13+type Bar struct {
14+ Font string `toml:"font"`
15+ Size float64 `toml:"size"` // font size in logical pixels
16+ // Scale multiplies the monitor scale (1.0 follows the monitor).
17+ Scale float64 `toml:"scale"`
18+ // Output is the wl_output name; empty lets the compositor choose.
19+ Output string `toml:"output"`
20+ // Background and Foreground are #rrggbb.
21+ Background string `toml:"background"`
22+ Foreground string `toml:"foreground"`
23+}
24+
25+// Module is one script.
26+type Module struct {
27+ Name string `toml:"name"`
28+ Zone string `toml:"zone"` // left | center | right
29+ Exec string `toml:"exec"`
30+}
31+
32+// Config is the whole file.
33+type Config struct {
34+ Bar Bar `toml:"bar"`
35+ Module []Module `toml:"module"`
36+}
37+
38+// Default returns the built-in settings.
39+func Default() Config {
40+ return Config{Bar: Bar{
41+ Font: "JetBrainsMono Nerd Font Mono", Size: 14, Scale: 1,
42+ Background: "#1e1e2e", Foreground: "#cdd6f4",
43+ }}
44+}
45+
46+// DefaultPath is $XDG_CONFIG_HOME/neferbar/config.toml.
47+func DefaultPath() string {
48+ dir := os.Getenv("XDG_CONFIG_HOME")
49+ if dir == "" {
50+ home, _ := os.UserHomeDir()
51+ dir = filepath.Join(home, ".config")
52+ }
53+ return filepath.Join(dir, "neferbar", "config.toml")
54+}
55+
56+// Load reads path over the defaults. A missing file at the default path is
57+// not an error.
58+func Load(path string, explicit bool) (Config, error) {
59+ c := Default()
60+ md, err := toml.DecodeFile(path, &c)
61+ if err != nil {
62+ if !explicit && os.IsNotExist(err) {
63+ return c, nil
64+ }
65+ return c, fmt.Errorf("config: %w", err)
66+ }
67+ if u := md.Undecoded(); len(u) > 0 {
68+ return c, fmt.Errorf("config: unknown key %s", u[0])
69+ }
70+ return c, c.Validate()
71+}
72+
73+// Validate checks every field.
74+func (c *Config) Validate() error {
75+ b := &c.Bar
76+ if b.Font == "" {
77+ return fmt.Errorf("config: bar.font is empty")
78+ }
79+ if b.Size < 6 || b.Size > 128 {
80+ return fmt.Errorf("config: bar.size %v out of range 6..128", b.Size)
81+ }
82+ if b.Scale < 0.5 || b.Scale > 8 {
83+ return fmt.Errorf("config: bar.scale %v out of range 0.5..8", b.Scale)
84+ }
85+ for _, col := range []string{b.Background, b.Foreground} {
86+ if _, err := ParseColor(col); err != nil {
87+ return err
88+ }
89+ }
90+ for i := range c.Module {
91+ m := &c.Module[i]
92+ if m.Exec == "" {
93+ return fmt.Errorf("config: module %d has no exec", i+1)
94+ }
95+ if m.Name == "" {
96+ m.Name = fmt.Sprintf("module%d", i+1)
97+ }
98+ switch m.Zone {
99+ case "left", "center", "right":
100+ default:
101+ return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)
102+ }
103+ }
104+ return nil
105+}
106+
107+// ParseColor parses #rrggbb.
108+func ParseColor(s string) ([3]uint8, error) {
109+ var r, g, b uint8
110+ if len(s) != 7 || s[0] != '#' {
111+ return [3]uint8{}, fmt.Errorf("config: color %q must be #rrggbb", s)
112+ }
113+ if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
114+ return [3]uint8{}, fmt.Errorf("config: color %q must be #rrggbb", s)
115+ }
116+ return [3]uint8{r, g, b}, nil
117+}
A internal/config/config_test.go +58 -0
@@ -0,0 +1,58 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func write(t *testing.T, body string) string {
11+ t.Helper()
12+ p := filepath.Join(t.TempDir(), "c.toml")
13+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
14+ t.Fatal(err)
15+ }
16+ return p
17+}
18+
19+func TestLoadDefaultsAndModules(t *testing.T) {
20+ c, err := Load(write(t, "[bar]\nsize = 16\n[[module]]\nzone = \"left\"\nexec = \"date\"\n"), true)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if c.Bar.Size != 16 || c.Bar.Scale != 1 || c.Bar.Font == "" {
25+ t.Fatalf("defaults not kept: %+v", c.Bar)
26+ }
27+ if c.Module[0].Name != "module1" {
28+ t.Fatalf("module name = %q", c.Module[0].Name)
29+ }
30+}
31+
32+func TestLoadErrors(t *testing.T) {
33+ for name, body := range map[string]string{
34+ "unknown key": "[bar]\nfnot = 1\n",
35+ "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36+ "no exec": "[[module]]\nzone = \"left\"\n",
37+ "bad size": "[bar]\nsize = 1\n",
38+ "bad scale": "[bar]\nscale = 20\n",
39+ "bad color": "[bar]\nbackground = \"red\"\n",
40+ "syntax error": "[bar\n",
41+ } {
42+ if _, err := Load(write(t, body), true); err == nil {
43+ t.Errorf("%s: want error", name)
44+ } else if !strings.Contains(err.Error(), "config") && !strings.Contains(err.Error(), "toml") {
45+ t.Errorf("%s: unexpected error %v", name, err)
46+ }
47+ }
48+}
49+
50+func TestMissingFile(t *testing.T) {
51+ p := filepath.Join(t.TempDir(), "none.toml")
52+ if _, err := Load(p, false); err != nil {
53+ t.Fatalf("default path missing must be fine: %v", err)
54+ }
55+ if _, err := Load(p, true); err == nil {
56+ t.Fatal("explicit path missing must fail")
57+ }
58+}
@@ -0,0 +1,58 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+func write(t *testing.T, body string) string {
11+ t.Helper()
12+ p := filepath.Join(t.TempDir(), "c.toml")
13+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
14+ t.Fatal(err)
15+ }
16+ return p
17+}
18+
19+func TestLoadDefaultsAndModules(t *testing.T) {
20+ c, err := Load(write(t, "[bar]\nsize = 16\n[[module]]\nzone = \"left\"\nexec = \"date\"\n"), true)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if c.Bar.Size != 16 || c.Bar.Scale != 1 || c.Bar.Font == "" {
25+ t.Fatalf("defaults not kept: %+v", c.Bar)
26+ }
27+ if c.Module[0].Name != "module1" {
28+ t.Fatalf("module name = %q", c.Module[0].Name)
29+ }
30+}
31+
32+func TestLoadErrors(t *testing.T) {
33+ for name, body := range map[string]string{
34+ "unknown key": "[bar]\nfnot = 1\n",
35+ "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36+ "no exec": "[[module]]\nzone = \"left\"\n",
37+ "bad size": "[bar]\nsize = 1\n",
38+ "bad scale": "[bar]\nscale = 20\n",
39+ "bad color": "[bar]\nbackground = \"red\"\n",
40+ "syntax error": "[bar\n",
41+ } {
42+ if _, err := Load(write(t, body), true); err == nil {
43+ t.Errorf("%s: want error", name)
44+ } else if !strings.Contains(err.Error(), "config") && !strings.Contains(err.Error(), "toml") {
45+ t.Errorf("%s: unexpected error %v", name, err)
46+ }
47+ }
48+}
49+
50+func TestMissingFile(t *testing.T) {
51+ p := filepath.Join(t.TempDir(), "none.toml")
52+ if _, err := Load(p, false); err != nil {
53+ t.Fatalf("default path missing must be fine: %v", err)
54+ }
55+ if _, err := Load(p, true); err == nil {
56+ t.Fatal("explicit path missing must fail")
57+ }
58+}
A internal/layout/layout.go +195 -0
@@ -0,0 +1,195 @@
1+// Package layout turns the ANSI frames of modules into one row of cells.
2+package layout
3+
4+import (
5+ vt "github.com/bnema/vev-vt"
6+
7+ "git.bnema.dev/bnema/neferbar/internal/gpu"
8+ "git.bnema.dev/bnema/neferbar/internal/module"
9+)
10+
11+// Source is one module and the cells parsed from its latest frame.
12+type Source struct {
13+ M *module.Module
14+
15+ screen *vt.Screen
16+ frame []byte // raw ANSI of the latest frame
17+ input []byte // reset + erase + frame, reused for each Write
18+ cells []gpu.Cell
19+ failed bool
20+}
21+
22+// Layout owns the sources and the composed row.
23+type Layout struct {
24+ cols int
25+ fg, bg [3]uint8
26+ sources []*Source
27+ row []gpu.Cell
28+}
29+
30+// New builds a layout for a row of cols cells.
31+func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {
32+ l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}
33+ for _, m := range mods {
34+ s := &Source{
35+ M: m,
36+ screen: vt.NewScreen(cols, 1),
37+ frame: make([]byte, 0, module.MaxFrame),
38+ input: make([]byte, 0, module.MaxFrame+16),
39+ cells: make([]gpu.Cell, 0, cols),
40+ }
41+ s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips
42+ l.sources = append(l.sources, s)
43+ }
44+ return l
45+}
46+
47+// Resize changes the row width and reparses the last frame of every module.
48+func (l *Layout) Resize(cols int) {
49+ l.cols = cols
50+ l.row = make([]gpu.Cell, cols)
51+ for _, s := range l.sources {
52+ s.screen = vt.NewScreen(cols, 1)
53+ s.screen.Write([]byte("\x1b[?7l"))
54+ s.cells = make([]gpu.Cell, 0, cols)
55+ if s.failed {
56+ s.cells = l.marker(s.cells, s.M.Name)
57+ } else if len(s.frame) > 0 {
58+ s.parse(l.fg, l.bg)
59+ }
60+ }
61+}
62+
63+// Update pulls new frames from every module and reparses the ones that
64+// changed. It reports whether the composed row may have changed.
65+func (l *Layout) Update() bool {
66+ changed := false
67+ for _, s := range l.sources {
68+ var ch, failed bool
69+ s.frame, ch, failed = s.M.Take(s.frame)
70+ if !ch {
71+ continue
72+ }
73+ changed = true
74+ s.failed = failed
75+ if failed {
76+ s.cells = l.marker(s.cells[:0], s.M.Name)
77+ continue
78+ }
79+ s.parse(l.fg, l.bg)
80+ }
81+ return changed
82+}
83+
84+// marker writes a short red "[name!]" for a module whose script is down.
85+func (l *Layout) marker(dst []gpu.Cell, name string) []gpu.Cell {
86+ red := [3]uint8{0xf3, 0x8b, 0xa8}
87+ add := func(r rune) { dst = append(dst, gpu.Cell{Rune: r, FG: red, BG: l.bg}) }
88+ add('[')
89+ for _, r := range name {
90+ add(r)
91+ }
92+ add('!')
93+ add(']')
94+ return dst
95+}
96+
97+func (s *Source) parse(fg, bg [3]uint8) {
98+ s.input = append(s.input[:0], "\x1b[0m\r\x1b[2K"...)
99+ s.input = append(s.input, s.frame...)
100+ s.screen.Write(s.input)
101+ cols := s.screen.Columns()
102+ // Trim trailing blank default cells so modules do not claim empty space.
103+ end := cols
104+ for end > 0 {
105+ c := s.screen.Cell(end-1, 0)
106+ if (c.Rune == ' ' || c.Rune == 0) && c.Style.Background < 0 && !c.Style.HasBackgroundRGB && !c.Style.Inverse {
107+ end--
108+ continue
109+ }
110+ break
111+ }
112+ s.cells = s.cells[:0]
113+ for x := 0; x < end; x++ {
114+ s.cells = append(s.cells, convert(s.screen.Cell(x, 0), fg, bg))
115+ }
116+}
117+
118+func convert(c vt.Cell, defFG, defBG [3]uint8) gpu.Cell {
119+ st := &c.Style
120+ fg, bg := defFG, defBG
121+ switch {
122+ case st.HasForegroundRGB:
123+ fg = [3]uint8{st.ForegroundRGB.R, st.ForegroundRGB.G, st.ForegroundRGB.B}
124+ case st.Foreground >= 0:
125+ fg = palette(st.Foreground)
126+ }
127+ switch {
128+ case st.HasBackgroundRGB:
129+ bg = [3]uint8{st.BackgroundRGB.R, st.BackgroundRGB.G, st.BackgroundRGB.B}
130+ case st.Background >= 0:
131+ bg = palette(st.Background)
132+ }
133+ if st.Inverse {
134+ fg, bg = bg, fg
135+ }
136+ if st.Attrs&vt.AttrDim != 0 {
137+ for i := range fg {
138+ fg[i] = uint8((int(fg[i])*2 + int(bg[i])) / 3)
139+ }
140+ }
141+ r := c.Rune
142+ if r == 0 || c.Continuation {
143+ r = ' '
144+ }
145+ return gpu.Cell{Rune: r, FG: fg, BG: bg, Bold: st.Bold}
146+}
147+
148+// Compose lays the zones out in one row: left from the left edge, right to
149+// the right edge, center in the gap between them. Overlap is cut from the
150+// center first, then from the left.
151+func (l *Layout) Compose() []gpu.Cell {
152+ blank := gpu.Cell{Rune: ' ', FG: l.fg, BG: l.bg}
153+ for i := range l.row {
154+ l.row[i] = blank
155+ }
156+ var left, center, right int
157+ for _, s := range l.sources {
158+ n := len(s.cells)
159+ switch s.M.Zone {
160+ case module.Left:
161+ left += n
162+ case module.Center:
163+ center += n
164+ default:
165+ right += n
166+ }
167+ }
168+ right = min(right, l.cols)
169+ left = min(left, l.cols-right)
170+ // Right zone, packed against the right edge.
171+ x := l.cols - right
172+ for _, s := range l.sources {
173+ if s.M.Zone == module.Right {
174+ x += copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
175+ }
176+ }
177+ // Left zone from the left edge, clipped to its budget.
178+ x, limit := 0, left
179+ for _, s := range l.sources {
180+ if s.M.Zone == module.Left && x < limit {
181+ x += copy(l.row[x:limit], s.cells)
182+ }
183+ }
184+ // Center zone in the free gap.
185+ gap := l.cols - right - left
186+ if center = min(center, gap); center > 0 {
187+ x, limit = left+(gap-center)/2, left+(gap-center)/2+center
188+ for _, s := range l.sources {
189+ if s.M.Zone == module.Center && x < limit {
190+ x += copy(l.row[x:limit], s.cells)
191+ }
192+ }
193+ }
194+ return l.row
195+}
@@ -0,0 +1,195 @@
1+// Package layout turns the ANSI frames of modules into one row of cells.
2+package layout
3+
4+import (
5+ vt "github.com/bnema/vev-vt"
6+
7+ "git.bnema.dev/bnema/neferbar/internal/gpu"
8+ "git.bnema.dev/bnema/neferbar/internal/module"
9+)
10+
11+// Source is one module and the cells parsed from its latest frame.
12+type Source struct {
13+ M *module.Module
14+
15+ screen *vt.Screen
16+ frame []byte // raw ANSI of the latest frame
17+ input []byte // reset + erase + frame, reused for each Write
18+ cells []gpu.Cell
19+ failed bool
20+}
21+
22+// Layout owns the sources and the composed row.
23+type Layout struct {
24+ cols int
25+ fg, bg [3]uint8
26+ sources []*Source
27+ row []gpu.Cell
28+}
29+
30+// New builds a layout for a row of cols cells.
31+func New(cols int, fg, bg [3]uint8, mods []*module.Module) *Layout {
32+ l := &Layout{cols: cols, fg: fg, bg: bg, row: make([]gpu.Cell, cols)}
33+ for _, m := range mods {
34+ s := &Source{
35+ M: m,
36+ screen: vt.NewScreen(cols, 1),
37+ frame: make([]byte, 0, module.MaxFrame),
38+ input: make([]byte, 0, module.MaxFrame+16),
39+ cells: make([]gpu.Cell, 0, cols),
40+ }
41+ s.screen.Write([]byte("\x1b[?7l")) // no autowrap: long text clips
42+ l.sources = append(l.sources, s)
43+ }
44+ return l
45+}
46+
47+// Resize changes the row width and reparses the last frame of every module.
48+func (l *Layout) Resize(cols int) {
49+ l.cols = cols
50+ l.row = make([]gpu.Cell, cols)
51+ for _, s := range l.sources {
52+ s.screen = vt.NewScreen(cols, 1)
53+ s.screen.Write([]byte("\x1b[?7l"))
54+ s.cells = make([]gpu.Cell, 0, cols)
55+ if s.failed {
56+ s.cells = l.marker(s.cells, s.M.Name)
57+ } else if len(s.frame) > 0 {
58+ s.parse(l.fg, l.bg)
59+ }
60+ }
61+}
62+
63+// Update pulls new frames from every module and reparses the ones that
64+// changed. It reports whether the composed row may have changed.
65+func (l *Layout) Update() bool {
66+ changed := false
67+ for _, s := range l.sources {
68+ var ch, failed bool
69+ s.frame, ch, failed = s.M.Take(s.frame)
70+ if !ch {
71+ continue
72+ }
73+ changed = true
74+ s.failed = failed
75+ if failed {
76+ s.cells = l.marker(s.cells[:0], s.M.Name)
77+ continue
78+ }
79+ s.parse(l.fg, l.bg)
80+ }
81+ return changed
82+}
83+
84+// marker writes a short red "[name!]" for a module whose script is down.
85+func (l *Layout) marker(dst []gpu.Cell, name string) []gpu.Cell {
86+ red := [3]uint8{0xf3, 0x8b, 0xa8}
87+ add := func(r rune) { dst = append(dst, gpu.Cell{Rune: r, FG: red, BG: l.bg}) }
88+ add('[')
89+ for _, r := range name {
90+ add(r)
91+ }
92+ add('!')
93+ add(']')
94+ return dst
95+}
96+
97+func (s *Source) parse(fg, bg [3]uint8) {
98+ s.input = append(s.input[:0], "\x1b[0m\r\x1b[2K"...)
99+ s.input = append(s.input, s.frame...)
100+ s.screen.Write(s.input)
101+ cols := s.screen.Columns()
102+ // Trim trailing blank default cells so modules do not claim empty space.
103+ end := cols
104+ for end > 0 {
105+ c := s.screen.Cell(end-1, 0)
106+ if (c.Rune == ' ' || c.Rune == 0) && c.Style.Background < 0 && !c.Style.HasBackgroundRGB && !c.Style.Inverse {
107+ end--
108+ continue
109+ }
110+ break
111+ }
112+ s.cells = s.cells[:0]
113+ for x := 0; x < end; x++ {
114+ s.cells = append(s.cells, convert(s.screen.Cell(x, 0), fg, bg))
115+ }
116+}
117+
118+func convert(c vt.Cell, defFG, defBG [3]uint8) gpu.Cell {
119+ st := &c.Style
120+ fg, bg := defFG, defBG
121+ switch {
122+ case st.HasForegroundRGB:
123+ fg = [3]uint8{st.ForegroundRGB.R, st.ForegroundRGB.G, st.ForegroundRGB.B}
124+ case st.Foreground >= 0:
125+ fg = palette(st.Foreground)
126+ }
127+ switch {
128+ case st.HasBackgroundRGB:
129+ bg = [3]uint8{st.BackgroundRGB.R, st.BackgroundRGB.G, st.BackgroundRGB.B}
130+ case st.Background >= 0:
131+ bg = palette(st.Background)
132+ }
133+ if st.Inverse {
134+ fg, bg = bg, fg
135+ }
136+ if st.Attrs&vt.AttrDim != 0 {
137+ for i := range fg {
138+ fg[i] = uint8((int(fg[i])*2 + int(bg[i])) / 3)
139+ }
140+ }
141+ r := c.Rune
142+ if r == 0 || c.Continuation {
143+ r = ' '
144+ }
145+ return gpu.Cell{Rune: r, FG: fg, BG: bg, Bold: st.Bold}
146+}
147+
148+// Compose lays the zones out in one row: left from the left edge, right to
149+// the right edge, center in the gap between them. Overlap is cut from the
150+// center first, then from the left.
151+func (l *Layout) Compose() []gpu.Cell {
152+ blank := gpu.Cell{Rune: ' ', FG: l.fg, BG: l.bg}
153+ for i := range l.row {
154+ l.row[i] = blank
155+ }
156+ var left, center, right int
157+ for _, s := range l.sources {
158+ n := len(s.cells)
159+ switch s.M.Zone {
160+ case module.Left:
161+ left += n
162+ case module.Center:
163+ center += n
164+ default:
165+ right += n
166+ }
167+ }
168+ right = min(right, l.cols)
169+ left = min(left, l.cols-right)
170+ // Right zone, packed against the right edge.
171+ x := l.cols - right
172+ for _, s := range l.sources {
173+ if s.M.Zone == module.Right {
174+ x += copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
175+ }
176+ }
177+ // Left zone from the left edge, clipped to its budget.
178+ x, limit := 0, left
179+ for _, s := range l.sources {
180+ if s.M.Zone == module.Left && x < limit {
181+ x += copy(l.row[x:limit], s.cells)
182+ }
183+ }
184+ // Center zone in the free gap.
185+ gap := l.cols - right - left
186+ if center = min(center, gap); center > 0 {
187+ x, limit = left+(gap-center)/2, left+(gap-center)/2+center
188+ for _, s := range l.sources {
189+ if s.M.Zone == module.Center && x < limit {
190+ x += copy(l.row[x:limit], s.cells)
191+ }
192+ }
193+ }
194+ return l.row
195+}
A internal/layout/layout_test.go +127 -0
@@ -0,0 +1,127 @@
1+package layout
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "testing"
7+
8+ "git.bnema.dev/bnema/neferbar/internal/module"
9+ "git.bnema.dev/bnema/neferbar/internal/racecheck"
10+)
11+
12+func newTestLayout(cols int) (*Layout, []*module.Module) {
13+ wake := make(chan struct{}, 1)
14+ log := slog.New(slog.DiscardHandler)
15+ mods := []*module.Module{
16+ module.New("l", module.Left, "true", wake, log),
17+ module.New("c", module.Center, "true", wake, log),
18+ module.New("r", module.Right, "true", wake, log),
19+ }
20+ return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, mods), mods
21+}
22+
23+func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }
24+
25+func rowText(l *Layout) string {
26+ row := l.Compose()
27+ rs := make([]rune, len(row))
28+ for i, c := range row {
29+ rs[i] = c.Rune
30+ }
31+ return string(rs)
32+}
33+
34+func TestZones(t *testing.T) {
35+ l, m := newTestLayout(20)
36+ publish(m[0], "LEFT")
37+ publish(m[1], "mid")
38+ publish(m[2], "RIGHT")
39+ if !l.Update() {
40+ t.Fatal("Update reported no change")
41+ }
42+ got := rowText(l)
43+ want := "LEFT mid RIGHT"
44+ if len(want) < 20 {
45+ want += " "[:20-len(want)]
46+ }
47+ if got[:4] != "LEFT" || got[15:] != "RIGHT" {
48+ t.Fatalf("row = %q", got)
49+ }
50+ if i := indexOf(got, "mid"); i < 4 || i > 15 {
51+ t.Fatalf("center misplaced in %q", got)
52+ }
53+}
54+
55+func indexOf(s, sub string) int {
56+ for i := 0; i+len(sub) <= len(s); i++ {
57+ if s[i:i+len(sub)] == sub {
58+ return i
59+ }
60+ }
61+ return -1
62+}
63+
64+func TestOverlapCutsCenterFirst(t *testing.T) {
65+ l, m := newTestLayout(12)
66+ publish(m[0], "AAAAA")
67+ publish(m[1], "CCCCCCCC")
68+ publish(m[2], "ZZZZZ")
69+ l.Update()
70+ got := rowText(l)
71+ if got[:5] != "AAAAA" || got[7:] != "ZZZZZ" {
72+ t.Fatalf("sides must survive: %q", got)
73+ }
74+}
75+
76+func TestColorsAndClip(t *testing.T) {
77+ l, m := newTestLayout(6)
78+ publish(m[0], "\x1b[31mR\x1b[0mx\x1b[1;38;2;1;2;3mverylongtext")
79+ l.Update()
80+ row := l.Compose()
81+ if row[0].Rune != 'R' || row[0].FG == l.fg {
82+ t.Fatalf("red cell: %+v", row[0])
83+ }
84+ if row[1].FG != l.fg {
85+ t.Fatalf("reset should restore default fg: %+v", row[1])
86+ }
87+ if !row[2].Bold || row[2].FG != [3]uint8{1, 2, 3} {
88+ t.Fatalf("bold truecolor cell: %+v", row[2])
89+ }
90+}
91+
92+func TestNewFrameReplacesOld(t *testing.T) {
93+ l, m := newTestLayout(10)
94+ publish(m[0], "long text")
95+ l.Update()
96+ publish(m[0], "ab")
97+ l.Update()
98+ if got := rowText(l); got[:2] != "ab" || got[2] != ' ' {
99+ t.Fatalf("old text lingers: %q", got)
100+ }
101+}
102+
103+// TestUpdateComposeAllocs: parsing a frame and composing the row must not
104+// allocate once the buffers are warm.
105+func TestUpdateComposeAllocs(t *testing.T) {
106+ if racecheck.Enabled {
107+ t.Skip("the race detector allocates")
108+ }
109+ l, m := newTestLayout(100)
110+ frames := make([]string, 8)
111+ for i := range frames {
112+ frames[i] = fmt.Sprintf("\x1b[48;2;%d;10;10m \x1b[0m clock %02d \x1b[1;32mok\x1b[0m \uf017", i*30, i)
113+ }
114+ n := 0
115+ step := func() {
116+ publish(m[2], frames[n%len(frames)])
117+ n++
118+ l.Update()
119+ l.Compose()
120+ }
121+ for i := 0; i < 20; i++ {
122+ step()
123+ }
124+ if got := testing.AllocsPerRun(500, step); got > 0 {
125+ t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)
126+ }
127+}
@@ -0,0 +1,127 @@
1+package layout
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "testing"
7+
8+ "git.bnema.dev/bnema/neferbar/internal/module"
9+ "git.bnema.dev/bnema/neferbar/internal/racecheck"
10+)
11+
12+func newTestLayout(cols int) (*Layout, []*module.Module) {
13+ wake := make(chan struct{}, 1)
14+ log := slog.New(slog.DiscardHandler)
15+ mods := []*module.Module{
16+ module.New("l", module.Left, "true", wake, log),
17+ module.New("c", module.Center, "true", wake, log),
18+ module.New("r", module.Right, "true", wake, log),
19+ }
20+ return New(cols, [3]uint8{200, 200, 200}, [3]uint8{10, 10, 10}, mods), mods
21+}
22+
23+func publish(m *module.Module, text string) { module.PublishForTest(m, []byte(text)) }
24+
25+func rowText(l *Layout) string {
26+ row := l.Compose()
27+ rs := make([]rune, len(row))
28+ for i, c := range row {
29+ rs[i] = c.Rune
30+ }
31+ return string(rs)
32+}
33+
34+func TestZones(t *testing.T) {
35+ l, m := newTestLayout(20)
36+ publish(m[0], "LEFT")
37+ publish(m[1], "mid")
38+ publish(m[2], "RIGHT")
39+ if !l.Update() {
40+ t.Fatal("Update reported no change")
41+ }
42+ got := rowText(l)
43+ want := "LEFT mid RIGHT"
44+ if len(want) < 20 {
45+ want += " "[:20-len(want)]
46+ }
47+ if got[:4] != "LEFT" || got[15:] != "RIGHT" {
48+ t.Fatalf("row = %q", got)
49+ }
50+ if i := indexOf(got, "mid"); i < 4 || i > 15 {
51+ t.Fatalf("center misplaced in %q", got)
52+ }
53+}
54+
55+func indexOf(s, sub string) int {
56+ for i := 0; i+len(sub) <= len(s); i++ {
57+ if s[i:i+len(sub)] == sub {
58+ return i
59+ }
60+ }
61+ return -1
62+}
63+
64+func TestOverlapCutsCenterFirst(t *testing.T) {
65+ l, m := newTestLayout(12)
66+ publish(m[0], "AAAAA")
67+ publish(m[1], "CCCCCCCC")
68+ publish(m[2], "ZZZZZ")
69+ l.Update()
70+ got := rowText(l)
71+ if got[:5] != "AAAAA" || got[7:] != "ZZZZZ" {
72+ t.Fatalf("sides must survive: %q", got)
73+ }
74+}
75+
76+func TestColorsAndClip(t *testing.T) {
77+ l, m := newTestLayout(6)
78+ publish(m[0], "\x1b[31mR\x1b[0mx\x1b[1;38;2;1;2;3mverylongtext")
79+ l.Update()
80+ row := l.Compose()
81+ if row[0].Rune != 'R' || row[0].FG == l.fg {
82+ t.Fatalf("red cell: %+v", row[0])
83+ }
84+ if row[1].FG != l.fg {
85+ t.Fatalf("reset should restore default fg: %+v", row[1])
86+ }
87+ if !row[2].Bold || row[2].FG != [3]uint8{1, 2, 3} {
88+ t.Fatalf("bold truecolor cell: %+v", row[2])
89+ }
90+}
91+
92+func TestNewFrameReplacesOld(t *testing.T) {
93+ l, m := newTestLayout(10)
94+ publish(m[0], "long text")
95+ l.Update()
96+ publish(m[0], "ab")
97+ l.Update()
98+ if got := rowText(l); got[:2] != "ab" || got[2] != ' ' {
99+ t.Fatalf("old text lingers: %q", got)
100+ }
101+}
102+
103+// TestUpdateComposeAllocs: parsing a frame and composing the row must not
104+// allocate once the buffers are warm.
105+func TestUpdateComposeAllocs(t *testing.T) {
106+ if racecheck.Enabled {
107+ t.Skip("the race detector allocates")
108+ }
109+ l, m := newTestLayout(100)
110+ frames := make([]string, 8)
111+ for i := range frames {
112+ frames[i] = fmt.Sprintf("\x1b[48;2;%d;10;10m \x1b[0m clock %02d \x1b[1;32mok\x1b[0m \uf017", i*30, i)
113+ }
114+ n := 0
115+ step := func() {
116+ publish(m[2], frames[n%len(frames)])
117+ n++
118+ l.Update()
119+ l.Compose()
120+ }
121+ for i := 0; i < 20; i++ {
122+ step()
123+ }
124+ if got := testing.AllocsPerRun(500, step); got > 0 {
125+ t.Errorf("Update+Compose allocates %.1f objects per frame; want 0", got)
126+ }
127+}
A internal/layout/palette.go +28 -0
@@ -0,0 +1,28 @@
1+package layout
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}
12+
13+// palette maps an xterm 256-color index to RGB.
14+func palette(i int) [3]uint8 {
15+ switch {
16+ case i < 0:
17+ return [3]uint8{}
18+ case i < 16:
19+ return base16[i]
20+ case i < 232:
21+ i -= 16
22+ return [3]uint8{cube[i/36], cube[i/6%6], cube[i%6]}
23+ case i < 256:
24+ v := uint8(8 + (i-232)*10)
25+ return [3]uint8{v, v, v}
26+ }
27+ return [3]uint8{0xff, 0xff, 0xff}
28+}
@@ -0,0 +1,28 @@
1+package layout
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}
12+
13+// palette maps an xterm 256-color index to RGB.
14+func palette(i int) [3]uint8 {
15+ switch {
16+ case i < 0:
17+ return [3]uint8{}
18+ case i < 16:
19+ return base16[i]
20+ case i < 232:
21+ i -= 16
22+ return [3]uint8{cube[i/36], cube[i/6%6], cube[i%6]}
23+ case i < 256:
24+ v := uint8(8 + (i-232)*10)
25+ return [3]uint8{v, v, v}
26+ }
27+ return [3]uint8{0xff, 0xff, 0xff}
28+}
A internal/module/module.go +184 -0
@@ -0,0 +1,184 @@
1+// Package module runs scripts that print ANSI frames on stdout.
2+//
3+// A frame ends at '\n' or '\f'. Only the most recent frame matters: a module
4+// that prints faster than the bar draws simply overwrites its pending frame,
5+// so a chatty script costs no memory and no allocation.
6+package module
7+
8+import (
9+ "bytes"
10+ "context"
11+ "io"
12+ "log/slog"
13+ "os"
14+ "os/exec"
15+ "sync"
16+ "syscall"
17+ "time"
18+)
19+
20+const (
21+ // MaxFrame bounds one frame; longer lines are truncated.
22+ MaxFrame = 16 << 10
23+ readSize = 32 << 10
24+
25+ minBackoff = 500 * time.Millisecond
26+ maxBackoff = 30 * time.Second
27+ // stableRun is how long a script must run before its backoff resets.
28+ stableRun = 10 * time.Second
29+)
30+
31+// Zone is where a module's text is placed.
32+type Zone uint8
33+
34+const (
35+ Left Zone = iota
36+ Center
37+ Right
38+)
39+
40+// Module is one running script.
41+type Module struct {
42+ Name string
43+ Zone Zone
44+ Exec string
45+
46+ mu sync.Mutex
47+ pending []byte // latest complete frame
48+ dirty bool
49+ failed bool // the script is not running; show an error marker
50+
51+ wake chan<- struct{}
52+ log *slog.Logger
53+}
54+
55+// New creates a module. wake receives a non-blocking signal after each frame.
56+func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
57+ return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
58+ pending: make([]byte, 0, MaxFrame)}
59+}
60+
61+// Take copies the pending frame into dst (reusing its capacity) and clears the
62+// dirty flag. changed is false when nothing new arrived. failed reports that
63+// the script is down.
64+func (m *Module) Take(dst []byte) (_ []byte, changed, failed bool) {
65+ m.mu.Lock()
66+ defer m.mu.Unlock()
67+ failed = m.failed
68+ if !m.dirty {
69+ return dst, false, failed
70+ }
71+ m.dirty = false
72+ return append(dst[:0], m.pending...), true, failed
73+}
74+
75+func (m *Module) signal() {
76+ select {
77+ case m.wake <- struct{}{}:
78+ default:
79+ }
80+}
81+
82+func (m *Module) publish(frame []byte) {
83+ m.mu.Lock()
84+ m.pending = append(m.pending[:0], frame...)
85+ m.dirty = true
86+ m.failed = false
87+ m.mu.Unlock()
88+ m.signal()
89+}
90+
91+func (m *Module) setFailed() {
92+ m.mu.Lock()
93+ m.failed = true
94+ m.dirty = true
95+ m.mu.Unlock()
96+ m.signal()
97+}
98+
99+// Run starts the script and restarts it with backoff until ctx ends. It
100+// blocks, so call it on its own goroutine.
101+func (m *Module) Run(ctx context.Context) {
102+ backoff := minBackoff
103+ for ctx.Err() == nil {
104+ started := time.Now()
105+ err := m.runOnce(ctx)
106+ if ctx.Err() != nil {
107+ return
108+ }
109+ m.log.Warn("module exited", "module", m.Name, "err", err)
110+ m.setFailed()
111+ if time.Since(started) > stableRun {
112+ backoff = minBackoff
113+ }
114+ select {
115+ case <-ctx.Done():
116+ return
117+ case <-time.After(backoff):
118+ }
119+ backoff = min(backoff*2, maxBackoff)
120+ }
121+}
122+
123+func (m *Module) runOnce(ctx context.Context) error {
124+ cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
125+ cmd.Stderr = &logWriter{log: m.log, name: m.Name}
126+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
127+ cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
128+ cmd.WaitDelay = 2 * time.Second
129+ cmd.Env = append(os.Environ(), "NEFERBAR_MODULE="+m.Name)
130+ out, err := cmd.StdoutPipe()
131+ if err != nil {
132+ return err
133+ }
134+ if err = cmd.Start(); err != nil {
135+ return err
136+ }
137+ m.readFrames(out)
138+ return cmd.Wait()
139+}
140+
141+// readFrames splits r into frames and publishes each one.
142+func (m *Module) readFrames(r io.Reader) {
143+ buf := make([]byte, readSize)
144+ frame := make([]byte, 0, MaxFrame)
145+ for {
146+ n, err := r.Read(buf)
147+ chunk := buf[:n]
148+ for len(chunk) > 0 {
149+ i := bytes.IndexAny(chunk, "\n\f")
150+ if i < 0 {
151+ frame = appendCapped(frame, chunk)
152+ break
153+ }
154+ frame = appendCapped(frame, chunk[:i])
155+ m.publish(frame)
156+ frame = frame[:0]
157+ chunk = chunk[i+1:]
158+ }
159+ if err != nil {
160+ return
161+ }
162+ }
163+}
164+
165+func appendCapped(dst, src []byte) []byte {
166+ if room := MaxFrame - len(dst); len(src) > room {
167+ src = src[:max(room, 0)]
168+ }
169+ return append(dst, src...)
170+}
171+
172+// logWriter forwards a script's stderr lines to the bar's log.
173+type logWriter struct {
174+ log *slog.Logger
175+ name string
176+}
177+
178+func (w *logWriter) Write(p []byte) (int, error) {
179+ w.log.Info("module stderr", "module", w.name, "text", string(bytes.TrimRight(p, "\n")))
180+ return len(p), nil
181+}
182+
183+// PublishForTest injects a frame as if the script had printed it.
184+func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
@@ -0,0 +1,184 @@
1+// Package module runs scripts that print ANSI frames on stdout.
2+//
3+// A frame ends at '\n' or '\f'. Only the most recent frame matters: a module
4+// that prints faster than the bar draws simply overwrites its pending frame,
5+// so a chatty script costs no memory and no allocation.
6+package module
7+
8+import (
9+ "bytes"
10+ "context"
11+ "io"
12+ "log/slog"
13+ "os"
14+ "os/exec"
15+ "sync"
16+ "syscall"
17+ "time"
18+)
19+
20+const (
21+ // MaxFrame bounds one frame; longer lines are truncated.
22+ MaxFrame = 16 << 10
23+ readSize = 32 << 10
24+
25+ minBackoff = 500 * time.Millisecond
26+ maxBackoff = 30 * time.Second
27+ // stableRun is how long a script must run before its backoff resets.
28+ stableRun = 10 * time.Second
29+)
30+
31+// Zone is where a module's text is placed.
32+type Zone uint8
33+
34+const (
35+ Left Zone = iota
36+ Center
37+ Right
38+)
39+
40+// Module is one running script.
41+type Module struct {
42+ Name string
43+ Zone Zone
44+ Exec string
45+
46+ mu sync.Mutex
47+ pending []byte // latest complete frame
48+ dirty bool
49+ failed bool // the script is not running; show an error marker
50+
51+ wake chan<- struct{}
52+ log *slog.Logger
53+}
54+
55+// New creates a module. wake receives a non-blocking signal after each frame.
56+func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
57+ return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
58+ pending: make([]byte, 0, MaxFrame)}
59+}
60+
61+// Take copies the pending frame into dst (reusing its capacity) and clears the
62+// dirty flag. changed is false when nothing new arrived. failed reports that
63+// the script is down.
64+func (m *Module) Take(dst []byte) (_ []byte, changed, failed bool) {
65+ m.mu.Lock()
66+ defer m.mu.Unlock()
67+ failed = m.failed
68+ if !m.dirty {
69+ return dst, false, failed
70+ }
71+ m.dirty = false
72+ return append(dst[:0], m.pending...), true, failed
73+}
74+
75+func (m *Module) signal() {
76+ select {
77+ case m.wake <- struct{}{}:
78+ default:
79+ }
80+}
81+
82+func (m *Module) publish(frame []byte) {
83+ m.mu.Lock()
84+ m.pending = append(m.pending[:0], frame...)
85+ m.dirty = true
86+ m.failed = false
87+ m.mu.Unlock()
88+ m.signal()
89+}
90+
91+func (m *Module) setFailed() {
92+ m.mu.Lock()
93+ m.failed = true
94+ m.dirty = true
95+ m.mu.Unlock()
96+ m.signal()
97+}
98+
99+// Run starts the script and restarts it with backoff until ctx ends. It
100+// blocks, so call it on its own goroutine.
101+func (m *Module) Run(ctx context.Context) {
102+ backoff := minBackoff
103+ for ctx.Err() == nil {
104+ started := time.Now()
105+ err := m.runOnce(ctx)
106+ if ctx.Err() != nil {
107+ return
108+ }
109+ m.log.Warn("module exited", "module", m.Name, "err", err)
110+ m.setFailed()
111+ if time.Since(started) > stableRun {
112+ backoff = minBackoff
113+ }
114+ select {
115+ case <-ctx.Done():
116+ return
117+ case <-time.After(backoff):
118+ }
119+ backoff = min(backoff*2, maxBackoff)
120+ }
121+}
122+
123+func (m *Module) runOnce(ctx context.Context) error {
124+ cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
125+ cmd.Stderr = &logWriter{log: m.log, name: m.Name}
126+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
127+ cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
128+ cmd.WaitDelay = 2 * time.Second
129+ cmd.Env = append(os.Environ(), "NEFERBAR_MODULE="+m.Name)
130+ out, err := cmd.StdoutPipe()
131+ if err != nil {
132+ return err
133+ }
134+ if err = cmd.Start(); err != nil {
135+ return err
136+ }
137+ m.readFrames(out)
138+ return cmd.Wait()
139+}
140+
141+// readFrames splits r into frames and publishes each one.
142+func (m *Module) readFrames(r io.Reader) {
143+ buf := make([]byte, readSize)
144+ frame := make([]byte, 0, MaxFrame)
145+ for {
146+ n, err := r.Read(buf)
147+ chunk := buf[:n]
148+ for len(chunk) > 0 {
149+ i := bytes.IndexAny(chunk, "\n\f")
150+ if i < 0 {
151+ frame = appendCapped(frame, chunk)
152+ break
153+ }
154+ frame = appendCapped(frame, chunk[:i])
155+ m.publish(frame)
156+ frame = frame[:0]
157+ chunk = chunk[i+1:]
158+ }
159+ if err != nil {
160+ return
161+ }
162+ }
163+}
164+
165+func appendCapped(dst, src []byte) []byte {
166+ if room := MaxFrame - len(dst); len(src) > room {
167+ src = src[:max(room, 0)]
168+ }
169+ return append(dst, src...)
170+}
171+
172+// logWriter forwards a script's stderr lines to the bar's log.
173+type logWriter struct {
174+ log *slog.Logger
175+ name string
176+}
177+
178+func (w *logWriter) Write(p []byte) (int, error) {
179+ w.log.Info("module stderr", "module", w.name, "text", string(bytes.TrimRight(p, "\n")))
180+ return len(p), nil
181+}
182+
183+// PublishForTest injects a frame as if the script had printed it.
184+func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
A internal/module/module_test.go +83 -0
@@ -0,0 +1,83 @@
1+package module
2+
3+import (
4+ "bytes"
5+ "context"
6+ "git.bnema.dev/bnema/neferbar/internal/racecheck"
7+ "log/slog"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+func newTest() (*Module, chan struct{}) {
14+ wake := make(chan struct{}, 1)
15+ return New("t", Left, "true", wake, slog.New(slog.DiscardHandler)), wake
16+}
17+
18+func TestReadFramesSplitsOnNewlineAndFormFeed(t *testing.T) {
19+ m, _ := newTest()
20+ m.readFrames(strings.NewReader("one\ntwo\fthree"))
21+ // "three" has no terminator yet: the latest complete frame is "two".
22+ got, changed, _ := m.Take(nil)
23+ if !changed || string(got) != "two" {
24+ t.Fatalf("got %q changed=%v, want latest complete frame %q", got, changed, "two")
25+ }
26+ if _, changed, _ = m.Take(got); changed {
27+ t.Fatal("Take must clear the dirty flag")
28+ }
29+}
30+
31+func TestFrameLongerThanMaxIsTruncated(t *testing.T) {
32+ m, _ := newTest()
33+ m.readFrames(strings.NewReader(strings.Repeat("x", MaxFrame*3) + "\n"))
34+ got, _, _ := m.Take(nil)
35+ if len(got) != MaxFrame {
36+ t.Fatalf("len = %d, want %d", len(got), MaxFrame)
37+ }
38+}
39+
40+func TestPublishWakesWithoutBlocking(t *testing.T) {
41+ m, wake := newTest()
42+ for i := 0; i < 1000; i++ { // nobody drains wake: must never block
43+ m.publish([]byte("x"))
44+ }
45+ if len(wake) != 1 {
46+ t.Fatalf("wake queue = %d, want 1", len(wake))
47+ }
48+}
49+
50+func TestRunStreamsAndRestarts(t *testing.T) {
51+ wake := make(chan struct{}, 1)
52+ m := New("t", Left, "printf 'hello\\n'", wake, slog.New(slog.DiscardHandler))
53+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
54+ defer cancel()
55+ go m.Run(ctx)
56+ var seen int
57+ deadline := time.After(4 * time.Second)
58+ for seen < 2 { // the script exits at once; it must be restarted
59+ select {
60+ case <-wake:
61+ if b, ch, failed := m.Take(nil); ch && !failed && bytes.Equal(b, []byte("hello")) {
62+ seen++
63+ }
64+ case <-deadline:
65+ t.Fatalf("saw %d frames, want 2 (restart)", seen)
66+ }
67+ }
68+}
69+
70+func TestPublishAllocs(t *testing.T) {
71+ if racecheck.Enabled {
72+ t.Skip("the race detector allocates")
73+ }
74+ m, _ := newTest()
75+ frame := []byte("\x1b[31mred\x1b[0m text")
76+ dst := make([]byte, 0, MaxFrame)
77+ if got := testing.AllocsPerRun(500, func() {
78+ m.publish(frame)
79+ dst, _, _ = m.Take(dst)
80+ }); got > 0 {
81+ t.Errorf("publish+Take allocates %.1f objects; want 0", got)
82+ }
83+}
@@ -0,0 +1,83 @@
1+package module
2+
3+import (
4+ "bytes"
5+ "context"
6+ "git.bnema.dev/bnema/neferbar/internal/racecheck"
7+ "log/slog"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+func newTest() (*Module, chan struct{}) {
14+ wake := make(chan struct{}, 1)
15+ return New("t", Left, "true", wake, slog.New(slog.DiscardHandler)), wake
16+}
17+
18+func TestReadFramesSplitsOnNewlineAndFormFeed(t *testing.T) {
19+ m, _ := newTest()
20+ m.readFrames(strings.NewReader("one\ntwo\fthree"))
21+ // "three" has no terminator yet: the latest complete frame is "two".
22+ got, changed, _ := m.Take(nil)
23+ if !changed || string(got) != "two" {
24+ t.Fatalf("got %q changed=%v, want latest complete frame %q", got, changed, "two")
25+ }
26+ if _, changed, _ = m.Take(got); changed {
27+ t.Fatal("Take must clear the dirty flag")
28+ }
29+}
30+
31+func TestFrameLongerThanMaxIsTruncated(t *testing.T) {
32+ m, _ := newTest()
33+ m.readFrames(strings.NewReader(strings.Repeat("x", MaxFrame*3) + "\n"))
34+ got, _, _ := m.Take(nil)
35+ if len(got) != MaxFrame {
36+ t.Fatalf("len = %d, want %d", len(got), MaxFrame)
37+ }
38+}
39+
40+func TestPublishWakesWithoutBlocking(t *testing.T) {
41+ m, wake := newTest()
42+ for i := 0; i < 1000; i++ { // nobody drains wake: must never block
43+ m.publish([]byte("x"))
44+ }
45+ if len(wake) != 1 {
46+ t.Fatalf("wake queue = %d, want 1", len(wake))
47+ }
48+}
49+
50+func TestRunStreamsAndRestarts(t *testing.T) {
51+ wake := make(chan struct{}, 1)
52+ m := New("t", Left, "printf 'hello\\n'", wake, slog.New(slog.DiscardHandler))
53+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
54+ defer cancel()
55+ go m.Run(ctx)
56+ var seen int
57+ deadline := time.After(4 * time.Second)
58+ for seen < 2 { // the script exits at once; it must be restarted
59+ select {
60+ case <-wake:
61+ if b, ch, failed := m.Take(nil); ch && !failed && bytes.Equal(b, []byte("hello")) {
62+ seen++
63+ }
64+ case <-deadline:
65+ t.Fatalf("saw %d frames, want 2 (restart)", seen)
66+ }
67+ }
68+}
69+
70+func TestPublishAllocs(t *testing.T) {
71+ if racecheck.Enabled {
72+ t.Skip("the race detector allocates")
73+ }
74+ m, _ := newTest()
75+ frame := []byte("\x1b[31mred\x1b[0m text")
76+ dst := make([]byte, 0, MaxFrame)
77+ if got := testing.AllocsPerRun(500, func() {
78+ m.publish(frame)
79+ dst, _, _ = m.Take(dst)
80+ }); got > 0 {
81+ t.Errorf("publish+Take allocates %.1f objects; want 0", got)
82+ }
83+}

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