Add statusbar packages

This commit is contained in:
dwrz
2026-10-26 22:16:41 +00:00
commit 22e6c5f676
14 changed files with 1014 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
package cpu
import (
"context"
"fmt"
"os"
"runtime"
"strings"
)
const path = "/proc/loadavg"
type Block struct{}
func New() *Block {
return &Block{}
}
func (b *Block) Name() string {
return "cpu"
}
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read %s: %v", path, err)
}
if fields := strings.Fields(string(out)); len(fields) >= 1 {
return fmt.Sprintf(" %s/%d", fields[0], runtime.NumCPU()), nil
} else {
return fmt.Sprintf(" /%d", runtime.NumCPU()), nil
}
}
+54
View File
@@ -0,0 +1,54 @@
package datetime
import (
"context"
"fmt"
"os/exec"
"strings"
)
type Block struct {
format string
label string
timezone string
}
type Config struct {
Format string `json:"format"`
Label string `json:"label"`
Timezone string `json:"timezone"`
}
func New(p Config) *Block {
if p.Timezone == "" {
p.Timezone = "UTC"
}
return &Block{
format: p.Format,
label: p.Label,
timezone: p.Timezone,
}
}
func (b *Block) Name() string {
return "datetime"
}
func (b *Block) Render(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "date", b.format)
cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", b.timezone))
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to exec: %v", err)
}
if b.label != "" {
return fmt.Sprintf(
"%s %s", b.label, strings.TrimSpace(string(out)),
), nil
} else {
return strings.TrimSpace(string(out)), nil
}
}
+66
View File
@@ -0,0 +1,66 @@
package disk
import (
"context"
"fmt"
"os/exec"
"strings"
)
// Config holds the configuration for the disk block.
type Config struct {
Mounts []string `json:"mounts"`
}
type Block struct {
mounts []string
}
func New(mounts ...string) *Block {
return &Block{mounts: mounts}
}
func (b *Block) Name() string {
return "disk"
}
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(ctx, "df", "-h").Output()
if err != nil {
return "", fmt.Errorf("exec df failed: %v", err)
}
var mounts = map[string]string{}
for i, l := range strings.Split(string(out), "\n") {
if i == 0 || len(l) == 0 {
continue
}
fields := strings.Fields(l)
if len(fields) < 6 {
continue
}
var icon rune = ''
switch fields[5] {
case "/":
icon = '/'
case "/home":
icon = ''
}
mounts[fields[5]] = fmt.Sprintf("%c %s", icon, fields[4])
}
var output strings.Builder
for _, m := range b.mounts {
if s, exists := mounts[m]; exists {
if output.Len() > 0 {
output.WriteRune(' ')
}
output.WriteString(s)
}
}
return output.String(), nil
}
+75
View File
@@ -0,0 +1,75 @@
package eth
import (
"context"
"fmt"
"net"
)
type Config struct {
Iface string `json:"iface"`
}
type Block struct {
iface string
}
func New(iface string) *Block {
return &Block{iface: iface}
}
func (b *Block) Name() string {
return "eth"
}
func (b *Block) Render(ctx context.Context) (string, error) {
iface, err := net.InterfaceByName(b.iface)
if err != nil {
if err.Error() == "route ip+net: no such network interface" {
return " ", nil
}
return "", fmt.Errorf(
"failed to get interface %s: %v", b.iface, err,
)
}
var ip4, ip6 string
addrs, err := iface.Addrs()
if err != nil {
return "", fmt.Errorf("failed to get addresses: %v", err)
}
for _, addr := range addrs {
a := addr.String()
if isIPv4(a) {
ip4 = a
continue
} else {
ip6 = a
}
}
switch {
case ip4 == "" && ip6 == "":
return " ", nil
case ip4 != "" && ip6 == "":
return fmt.Sprintf(" %s", ip4), nil
case ip4 == "" && ip6 != "":
return fmt.Sprintf(" %s", ip6), nil
default:
return fmt.Sprintf(" %s %s", ip4, ip6), nil
}
}
func isIPv4(s string) bool {
for i := 0; i < len(s); i++ {
switch s[i] {
case '.':
return true
case ':':
return false
}
}
return false
}
+31
View File
@@ -0,0 +1,31 @@
package light
import (
"context"
"fmt"
"os/exec"
"strings"
)
type Block struct{}
func New() *Block {
return &Block{}
}
func (b *Block) Name() string {
return "light"
}
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(ctx, "brightnessctl", "-m").Output()
if err != nil {
return "", fmt.Errorf("failed to exec: %v", err)
}
if fields := strings.Split(string(out), ","); len(fields) >= 4 {
return fmt.Sprintf(" %s", fields[3]), nil
} else {
return " ", nil
}
}
+79
View File
@@ -0,0 +1,79 @@
package memory
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
)
type Block struct{}
func New() *Block {
return &Block{}
}
func (b *Block) Name() string {
return "memory"
}
func (b *Block) Render(ctx context.Context) (string, error) {
var output strings.Builder
out, err := exec.CommandContext(ctx, "free", "-m").Output()
if err != nil {
return "", fmt.Errorf("failed to exec: %v", err)
}
for l := range strings.SplitSeq(string(out), "\n") {
if len(l) == 0 {
continue
}
fields := strings.Fields(l)
if len(fields) < 3 {
continue
}
switch fields[0] {
case "Mem:":
total, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return "", fmt.Errorf(
"failed to parse total memory: %v", err,
)
}
used, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
return "", fmt.Errorf(
"failed to parse used memory: %v", err,
)
}
if total > 0 {
fmt.Fprintf(&output, " %.0f%%", (used/total)*100)
}
case "Swap:":
total, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return "", fmt.Errorf(
"failed to parse total memory: %v", err,
)
}
used, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
return "", fmt.Errorf(
"failed to parse used memory: %v", err,
)
}
if total > 0 {
if output.Len() > 0 {
output.WriteRune(' ')
}
fmt.Fprintf(&output, " %.0f%%", (used/total)*100)
}
}
}
return output.String(), nil
}
+33
View File
@@ -0,0 +1,33 @@
package mic
import (
"context"
"fmt"
"os/exec"
"strings"
)
type Block struct{}
func New() *Block {
return &Block{}
}
func (b *Block) Name() string {
return "mic"
}
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(
ctx, "pactl", "get-source-mute", "@DEFAULT_SOURCE@",
).Output()
if err != nil {
return "", fmt.Errorf("exec pactl failed: %v", err)
}
if strings.Contains(string(out), "yes") {
return "", nil
}
return " OPEN", nil
}
+53
View File
@@ -0,0 +1,53 @@
package mlight
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
)
const (
// VCPBrightnessCode is the VCP code for monitor brightness.
VCPBrightnessCode = "10"
)
// Block implements the statusbar.Block interface for monitor brightness.
type Block struct{}
// New returns a new mlight Block.
func New() *Block {
return &Block{}
}
// Name returns the name of the block.
func (b *Block) Name() string {
return "mlight"
}
// Render displays the current monitor brightness.
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(
ctx,
"ddcutil",
"--terse",
"getvcp",
VCPBrightnessCode,
).Output()
if err != nil {
return "", fmt.Errorf("failed to exec ddcutil: %v", err)
}
fields := strings.Fields(string(out))
if len(fields) < 4 {
return " ", nil
}
val, err := strconv.Atoi(fields[3])
if err != nil {
return " ", nil
}
return fmt.Sprintf(" %d%%", val), nil
}
+169
View File
@@ -0,0 +1,169 @@
package power
import (
"bufio"
"context"
"fmt"
"math"
"os"
"slices"
"strconv"
"strings"
)
type Config struct {
Path string `json:"path"`
}
// https://www.kernel.org/doc/html/latest/power/power_supply_class.html
const Path = "/sys/class/power_supply"
type Block struct {
path string
}
func New(path string) *Block {
return &Block{path: path}
}
func (b *Block) Name() string {
return "power"
}
func (b *Block) Render(ctx context.Context) (string, error) {
// Get the power supplies.
var supplies []string
files, err := os.ReadDir(b.path)
if err != nil {
return "", fmt.Errorf("failed to read dir %s: %v", b.path, err)
}
for _, f := range files {
name := f.Name()
if name == "AC" || strings.Contains(name, "BAT") {
supplies = append(supplies, f.Name())
}
}
slices.Sort(supplies)
var sections = []string{}
for _, s := range supplies {
path := fmt.Sprintf("%s/%s/uevent", b.path, s)
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf(
"failed to open %s: %v", path, err,
)
}
switch {
case s == "AC":
sections = append(sections, outputAC(f))
case strings.Contains(s, "BAT"):
sections = append(sections, outputBAT(f))
}
f.Close()
}
var output strings.Builder
for i, s := range sections {
if i > 0 {
output.WriteRune(' ')
output.WriteRune(' ')
}
output.WriteString(s)
}
return output.String(), nil
}
func outputAC(f *os.File) string {
var scanner = bufio.NewScanner(f)
for scanner.Scan() {
k, v, found := strings.Cut(scanner.Text(), "=")
if !found {
continue
}
if k == "POWER_SUPPLY_ONLINE" {
online, err := strconv.ParseBool(v)
if err != nil {
break
}
if online {
return ""
} else {
return ""
}
}
}
return ""
}
func outputBAT(f *os.File) string {
// Compile the stats.
var (
stats = map[string]string{}
scanner = bufio.NewScanner(f)
)
for scanner.Scan() {
k, v, found := strings.Cut(scanner.Text(), "=")
if !found {
continue
}
// Exit early if the battery is not present.
if k == "POWER_SUPPLY_PRESENT" && v == "0" {
return ""
}
stats[k] = v
}
// Assemble output.
var icon rune
switch stats["POWER_SUPPLY_STATUS"] {
case "Charging":
icon = ''
case "Discharging":
icon = ''
case "Full":
icon = ''
default:
icon = ''
}
// Get capacity.
var capacity string
if s, exists := stats["POWER_SUPPLY_CAPACITY"]; exists {
capacity = s + "%"
}
// Get remaining.
pn, _ := strconv.ParseFloat(stats["POWER_SUPPLY_POWER_NOW"], 64)
en, _ := strconv.ParseFloat(stats["POWER_SUPPLY_ENERGY_NOW"], 64)
if r := remaining(en, pn); r != "" {
return fmt.Sprintf("%c %s %s", icon, capacity, r)
}
return fmt.Sprintf("%c %s", icon, capacity)
}
func remaining(energy, power float64) string {
if energy == 0 || power == 0 {
return ""
}
// Calculate the remaining hours.
var hours = energy / power
if hours == 0 {
return ""
}
return fmt.Sprintf(
"%d:%02d",
int(hours),
int((hours-math.Floor(hours))*60),
)
}
+83
View File
@@ -0,0 +1,83 @@
package statusbar
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
type Block interface {
Name() string
Render(ctx context.Context) (string, error)
}
type Parameters struct {
Blocks []Block
Log *slog.Logger
Separator string
}
type StatusBar struct {
b *strings.Builder
blocks []Block
l *slog.Logger
sep string
}
func (s *StatusBar) Render(ctx context.Context) string {
defer s.b.Reset()
fmt.Fprintf(s.b, "%s ", s.sep)
var (
timeout, cancel = context.WithTimeout(ctx, time.Second)
outputs = make([]string, len(s.blocks))
wg sync.WaitGroup
)
defer cancel()
wg.Add(len(s.blocks))
for i, b := range s.blocks {
go func(i int, b Block) {
defer wg.Done()
text, err := b.Render(timeout)
if err != nil {
s.l.ErrorContext(
timeout,
"failed to render block",
slog.String("name", b.Name()),
slog.Any("error", err),
)
outputs[i] = ""
} else {
outputs[i] = text
}
}(i, b)
}
wg.Wait()
for i, o := range outputs {
s.b.WriteString(o)
if i < len(outputs)-1 {
fmt.Fprintf(s.b, " %s ", s.sep)
}
}
return s.b.String()
}
func New(p Parameters) *StatusBar {
return &StatusBar{
b: &strings.Builder{},
blocks: p.Blocks,
l: p.Log,
sep: p.Separator,
}
}
+108
View File
@@ -0,0 +1,108 @@
package temp
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
)
// Sensor describes a single temperature sensor to be read.
type Sensor struct {
Label string `json:"label"`
Path []string `json:"path"`
}
// Config holds the configuration for the temp block.
type Config struct {
Sensors []Sensor `json:"sensors"`
}
type Block struct {
config Config
}
func New(c Config) *Block {
return &Block{config: c}
}
func (b *Block) Name() string {
return "temp"
}
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(ctx, "sensors", "-j").Output()
if err != nil {
return "", fmt.Errorf("exec sensors failed: %v", err)
}
if len(b.config.Sensors) == 0 {
return "", nil
}
var parts = make([]string, 0, len(b.config.Sensors))
for _, sensor := range b.config.Sensors {
temp, err := parse(out, sensor.Path)
if err != nil {
return "", fmt.Errorf(
"failed to get temp for %v: %w",
sensor.Path, err,
)
}
var renderedSensor string
if sensor.Label != "" {
renderedSensor = fmt.Sprintf(
"%s %.0f℃", sensor.Label, temp,
)
} else {
renderedSensor = fmt.Sprintf("%.0f℃", temp)
}
parts = append(parts, renderedSensor)
}
return strings.Join(parts, " "), nil
}
// parse traverses the json data using the given path to find a temperature
// value.
func parse(data []byte, path []string) (float64, error) {
if len(path) == 0 {
return 0, fmt.Errorf("sensor path cannot be empty")
}
// We start with the full JSON output.
var current = json.RawMessage(data)
// For each key in the path, we dive one level deeper into the JSON
// structure.
for i, key := range path {
var m map[string]json.RawMessage
if err := json.Unmarshal(current, &m); err != nil {
return 0, fmt.Errorf(
"failed to unmarshal object at path segment %d ('%s'): %w",
i, key, err,
)
}
var ok bool
current, ok = m[key]
if !ok {
return 0, fmt.Errorf(
"key not found at path segment %d ('%s')",
i, key,
)
}
}
// After the loop, 'current' holds the raw JSON for the temperature
// value.
var temp float64
if err := json.Unmarshal(current, &temp); err != nil {
return 0, fmt.Errorf(
"final value at path %v is not a number: %w", path, err,
)
}
return temp, nil
}
+44
View File
@@ -0,0 +1,44 @@
package volume
import (
"context"
"fmt"
"os/exec"
"strings"
)
type Block struct{}
func New() *Block {
return &Block{}
}
func (b *Block) Name() string {
return "volume"
}
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(
ctx, "pactl", "get-sink-mute", "@DEFAULT_SINK@",
).Output()
if err != nil {
return "", fmt.Errorf("exec pactl failed: %v", err)
}
if strings.Contains(string(out), "yes") {
return "", nil
}
out, err = exec.CommandContext(
ctx, "pactl", "get-sink-volume", "@DEFAULT_SINK@",
).Output()
if err != nil {
return "", fmt.Errorf("exec pactl failed: %v", err)
}
if fields := strings.Fields(string(out)); len(fields) < 5 {
return " ", nil
} else {
return fmt.Sprintf(" %s", fields[4]), nil
}
}
+65
View File
@@ -0,0 +1,65 @@
package wlan
import (
"bufio"
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// Config holds the configuration for the wlan block.
type Config struct {
Iface string `json:"iface"`
}
type Block struct {
iface string
}
func New(iface string) *Block {
return &Block{iface: iface}
}
func (b *Block) Name() string {
return "wlan"
}
// TODO: signal strength icon.
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(
ctx, "iwctl", "station", b.iface, "show",
).Output()
if err != nil {
return "", fmt.Errorf("exec iwctl failed: %v", err)
}
var (
scanner = bufio.NewScanner(bytes.NewReader(out))
state, network, ip, rssi string
)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
switch {
case fields[0] == "State":
state = fields[1]
case fields[0] == "Connected":
network = fields[2]
case fields[0] == "IPv4":
ip = fields[2]
case fields[0] == "RSSI":
rssi = fields[1] + fields[2]
}
}
if state == "disconnected" {
return " ", nil
}
return fmt.Sprintf(" %s %s %s", network, ip, rssi), nil
}
+120
View File
@@ -0,0 +1,120 @@
package wwan
import (
"bufio"
"bytes"
"context"
"fmt"
"net"
"os/exec"
"path"
"strings"
)
type Config struct {
Iface string `json:"iface"`
}
type Block struct {
iface string
}
func New(iface string) *Block {
return &Block{iface: iface}
}
func (b *Block) Name() string {
return "wwan"
}
// TODO: signal strength icon.
func (b *Block) Render(ctx context.Context) (string, error) {
out, err := exec.CommandContext(ctx, "mmcli", "--list-modems").Output()
if err != nil {
return "", fmt.Errorf("exec mmcli failed: %v", err)
}
fields := strings.Fields(string(out))
if len(fields) == 0 {
return "", fmt.Errorf("unexpected mmcli output: %q", string(out))
}
modem := path.Base(fields[0])
out, err = exec.CommandContext(
ctx, "mmcli", "-m", modem, "--output-keyvalue",
).Output()
if err != nil {
return "", fmt.Errorf("exec mmcli failed: %v", err)
}
var (
scanner = bufio.NewScanner(bytes.NewReader(out))
state, signal string
)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
continue
}
switch {
case fields[0] == "modem.generic.state":
state = fields[2]
case fields[0] == "modem.generic.signal-quality.value":
signal = fields[2]
}
}
if state == "disabled" {
return " ", nil
}
// Get the IP address.
iface, err := net.InterfaceByName(b.iface)
if err != nil {
return "", fmt.Errorf(
"failed to get interface %s: %v", b.iface, err,
)
}
var ip4, ip6 string
addrs, err := iface.Addrs()
if err != nil {
return "", fmt.Errorf("failed to get addresses: %v", err)
}
for _, addr := range addrs {
a := addr.String()
if isIPv4(a) {
ip4 = a
continue
} else {
ip6 = a
}
}
switch {
case ip4 == "" && ip6 == "":
return fmt.Sprintf(" %s%%", signal), nil
case ip4 != "" && ip6 == "":
return fmt.Sprintf(" %s %s%%", ip4, signal), nil
case ip4 == "" && ip6 != "":
return fmt.Sprintf(" %s %s%%", ip6, signal), nil
default:
return fmt.Sprintf(" %s %s %s%%", ip4, ip6, signal), nil
}
}
func isIPv4(s string) bool {
for i := 0; i < len(s); i++ {
switch s[i] {
case '.':
return true
case ':':
return false
}
}
return false
}