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 ":…", 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 }