Files
mesh-catalog/modules/i3status-rust/cmd/i3status-rust-tools/bar.go
T
jochen 838e1c2616 i3status-rust: the bars as a module, claiming node-bar (hq ADR 0208)
Owns both bars and their icons, the bar blocks as an i3 drop-in; the battery,
GPU and headset blocks leave (hardware and a person's devices), the weather needs
no key; the update count and the bar watchdog become module code, the watchdog
started once per session. Go tools reload, blocks, block-run and themes.
2026-10-04 13:15:39 +02:00

307 lines
8.3 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
var bars = []string{"top", "bottom"}
// A bar runs this long when a tool reads its values: long enough for every block's first update.
const runFor = 4 * time.Second
func configDir() string { return filepath.Join(operatorHome(), ".config", "i3status-rust") }
func barFile(bar string) string { return filepath.Join(configDir(), bar+"-bar.toml") }
func barsOf(bar string) ([]string, error) {
if bar == "" {
return bars, nil
}
for _, b := range bars {
if b == bar {
return []string{bar}, nil
}
}
return nil, fmt.Errorf("bar %q is top or bottom", bar)
}
// ReloadResult is what i3status_rust_reload answers.
type ReloadResult struct {
Reached []int `json:"reached"`
Note string `json:"note"`
}
// Reload restarts every running i3status-rs in place, which re-reads its file (SIGUSR2).
func Reload() ReloadResult {
reached := signalAll("i3status-rs", syscall.SIGUSR2)
if reached == nil {
return ReloadResult{Reached: []int{}, Note: "no bar is running: i3 starts them with the session"}
}
return ReloadResult{Reached: reached, Note: "each bar re-read its configuration"}
}
// Config is a bar's file cut into the part before its blocks and each block's own text.
type Config struct {
Header string
Blocks []string
}
var blockStart = regexp.MustCompile(`(?m)^\[\[block\]\]\s*$`)
func splitConfig(raw string) Config {
idx := blockStart.FindAllStringIndex(raw, -1)
if len(idx) == 0 {
return Config{Header: raw}
}
c := Config{Header: raw[:idx[0][0]]}
for i, at := range idx {
end := len(raw)
if i+1 < len(idx) {
end = idx[i+1][0]
}
c.Blocks = append(c.Blocks, raw[at[0]:end])
}
return c
}
// Block is one block of a bar.
type Block struct {
Index int `json:"index"`
Kind string `json:"block"`
Settings map[string]string `json:"settings"`
Text *string `json:"text,omitempty"`
State string `json:"state,omitempty"`
}
var keyValue = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
// describe reads a block's own top-level settings (not its sub-tables, such as clicks), as written.
func describe(index int, raw string) Block {
b := Block{Index: index, Settings: map[string]string{}}
for _, line := range strings.Split(raw, "\n")[1:] {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
break
}
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if m := keyValue.FindStringSubmatch(line); m != nil {
v := strings.Trim(m[2], `"`)
if m[1] == "block" {
b.Kind = v
} else {
b.Settings[m[1]] = v
}
}
}
return b
}
// BarBlocks is one bar's blocks.
type BarBlocks struct {
Bar string `json:"bar"`
File string `json:"file"`
Blocks []Block `json:"blocks"`
Error string `json:"error,omitempty"`
}
// Blocks describes each bar's blocks, and with values what each shows now.
func Blocks(bar string, values bool) ([]BarBlocks, error) {
which, err := barsOf(bar)
if err != nil {
return nil, err
}
var out []BarBlocks
for _, b := range which {
file := barFile(b)
raw, err := os.ReadFile(file)
if err != nil {
out = append(out, BarBlocks{Bar: b, File: file, Blocks: []Block{}, Error: err.Error()})
continue
}
c := splitConfig(string(raw))
bb := BarBlocks{Bar: b, File: file, Blocks: []Block{}}
for i, text := range c.Blocks {
bb.Blocks = append(bb.Blocks, describe(i, text))
}
if values {
shown, err := runBar(file)
if err != nil {
bb.Error = err.Error()
}
for i := range bb.Blocks {
if w, ok := shown[i]; ok {
text := w.text
bb.Blocks[i].Text, bb.Blocks[i].State = &text, w.state
}
}
}
out = append(out, bb)
}
return out, nil
}
type widgetText struct{ text, state string }
// runBar runs i3status-rs on a file for a moment and answers each block's latest text, by index.
// i3status-rs names each block's widgets by an instance "<index>:…", which is how they are told apart.
func runBar(file string) (map[int]widgetText, error) {
s := findEnvironment() // the session when there is one; blocks that need none run without
r, err := s.run(runFor, "", "i3status-rs", file)
if err != nil && !errors.Is(err, ErrTimedOut) {
return nil, err
}
shown := parseStatus(r.Stdout)
if len(shown) == 0 {
msg := strings.TrimSpace(r.Stderr)
if msg == "" {
msg = "the bar showed nothing within " + runFor.String()
}
return shown, errors.New(msg)
}
return shown, nil
}
// parseStatus reads i3bar's protocol — a header, "[", then one JSON array per update — and answers
// the last complete update's text per block index.
func parseStatus(stream string) map[int]widgetText {
var last []map[string]any
for _, line := range strings.Split(stream, "\n") {
line = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), ","), ",")
if !strings.HasPrefix(line, "[{") && line != "[]" {
continue
}
var update []map[string]any
if json.Unmarshal([]byte(line), &update) == nil {
last = update
}
}
out := map[int]widgetText{}
for _, w := range last {
instance, _ := w["instance"].(string)
head, _, ok := strings.Cut(instance, ":")
if !ok {
continue // a separator
}
i, err := strconv.Atoi(head)
if err != nil {
continue
}
text, _ := w["full_text"].(string)
cur := out[i]
cur.text = strings.TrimSpace(strings.TrimSpace(cur.text) + " " + strings.TrimSpace(text))
if colour, _ := w["color"].(string); strings.EqualFold(colour, "#FF0000FF") {
cur.state = "critical"
}
out[i] = cur
}
return out
}
// RunResult is what i3status_rust_block_run answers.
type RunResult struct {
Bar string `json:"bar"`
Block Block `json:"block"`
Error string `json:"error,omitempty"`
}
// BlockRun runs one block of a bar alone, with the bar's theme and icons.
func BlockRun(bar string, index int) (RunResult, error) {
if _, err := barsOf(bar); err != nil || bar == "" {
return RunResult{}, fmt.Errorf("bar %q is top or bottom", bar)
}
raw, err := os.ReadFile(barFile(bar))
if err != nil {
return RunResult{}, err
}
c := splitConfig(string(raw))
if index >= len(c.Blocks) {
return RunResult{}, fmt.Errorf("the %s bar has %d blocks, numbered from 0", bar, len(c.Blocks))
}
tmp, err := os.CreateTemp("", "i3status-rust-block-*.toml")
if err != nil {
return RunResult{}, err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(c.Header + c.Blocks[index]); err != nil {
tmp.Close()
return RunResult{}, err
}
tmp.Close()
out := RunResult{Bar: bar, Block: describe(index, c.Blocks[index])}
shown, err := runBar(tmp.Name())
if err != nil {
out.Error = err.Error()
}
if w, ok := shown[0]; ok {
text := w.text
out.Block.Text, out.Block.State = &text, w.state
}
return out, nil
}
// Theme is one bar theme.
type Theme struct {
Name string `json:"name"`
File string `json:"file"`
Source string `json:"source"`
}
// ThemesResult is what i3status_rust_themes answers.
type ThemesResult struct {
InUse map[string]string `json:"in_use"`
Themes []Theme `json:"themes"`
}
type themeDir struct{ path, source string }
func themeDirs() []themeDir {
return []themeDir{
{filepath.Join(configDir(), "themes"), "the account's"},
{filepath.Join(operatorHome(), ".local", "share", "i3status-rust", "themes"), "the account's"},
{"/usr/share/i3status-rust/themes", "the distribution's"},
}
}
var themeName = regexp.MustCompile(`(?ms)^\[theme\]\s*$.*?^theme\s*=\s*"([^"]+)"`)
// Themes lists the themes, the first directory winning a name, and the one each bar names.
func Themes(dirs []themeDir) ThemesResult {
out := ThemesResult{InUse: map[string]string{}, Themes: []Theme{}}
for _, b := range bars {
if raw, err := os.ReadFile(barFile(b)); err == nil {
if m := themeName.FindStringSubmatch(string(raw)); m != nil {
out.InUse[b] = m[1]
}
}
}
seen := map[string]bool{}
for _, d := range dirs {
entries, err := os.ReadDir(d.path)
if err != nil {
continue
}
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".toml")
if !ok || seen[name] {
continue
}
seen[name] = true
out.Themes = append(out.Themes, Theme{Name: name, File: filepath.Join(d.path, e.Name()), Source: d.source})
}
}
sort.Slice(out.Themes, func(i, j int) bool { return out.Themes[i].Name < out.Themes[j].Name })
return out
}