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.
307 lines
8.3 KiB
Go
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
|
|
}
|