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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||