Files
mesh-catalog/modules/picom/cmd/picom-tools/picom.go
T
jochen 04894e6618 picom: the compositor as a module, claiming node-compositor (hq ADR 0208)
Owns its configuration, moved to picom's window rules; started once from the
session's xinitrc slot; Go tools restart, rules, window-opacity and toggle, which
find the operator's X session from the window manager's environment.
2026-10-04 13:15:39 +02:00

294 lines
8.9 KiB
Go

package main
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
// unit is the name picom runs under in the account's service manager when a tool starts it. The
// session start runs its own, and a tool that starts one first ends any other (Toggle, Restart).
const unit = "picom"
func configPath() string { return filepath.Join(operatorHome(), ".config", "picom", "picom.conf") }
// Running is picom's processes of this account.
func running() []int { return processesOf("picom") }
// RestartResult is what picom_restart answers.
type RestartResult struct {
Action string `json:"action"`
PIDs []int `json:"pids"`
Note string `json:"note,omitempty"`
}
// Restart tells a running picom to reinitialise, or starts one.
func Restart(hard bool) (RestartResult, error) {
if pids := running(); len(pids) > 0 && !hard {
return RestartResult{Action: "reinitialised", PIDs: signalAll("picom", syscall.SIGUSR1),
Note: "picom re-read " + configPath()}, nil
}
s, err := findSession()
if err != nil {
return RestartResult{}, err
}
if err := stopAll(); err != nil {
return RestartResult{}, err
}
if err := s.detach(unit, "picom", "--config", configPath()); err != nil {
return RestartResult{}, err
}
pids := waitFor(func() []int { return running() }, 3*time.Second)
return RestartResult{Action: "started", PIDs: pids,
Note: "started under the account's service manager as " + unit + ".service"}, nil
}
// stopAll ends every picom of this account and waits for them to go.
func stopAll() error {
signalAll("picom", syscall.SIGTERM)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(running()) == 0 {
return nil
}
time.Sleep(100 * time.Millisecond)
}
if left := running(); len(left) > 0 {
return fmt.Errorf("picom %v did not end within 3s", left)
}
return nil
}
func waitFor(get func() []int, within time.Duration) []int {
deadline := time.Now().Add(within)
for {
if got := get(); len(got) > 0 || time.Now().After(deadline) {
return got
}
time.Sleep(100 * time.Millisecond)
}
}
// ToggleResult is what picom_toggle answers.
type ToggleResult struct {
Compositing bool `json:"compositing"`
PIDs []int `json:"pids"`
Note string `json:"note"`
}
// Toggle stops or starts compositing; want nil flips it.
func Toggle(want *bool) (ToggleResult, error) {
on := len(running()) == 0
if want != nil {
on = *want
}
if !on {
if err := stopAll(); err != nil {
return ToggleResult{}, err
}
return ToggleResult{Compositing: false, PIDs: []int{},
Note: "picom ended; the session start runs it again at the next login, or call picom_toggle with on"}, nil
}
if pids := running(); len(pids) > 0 {
return ToggleResult{Compositing: true, PIDs: pids, Note: "picom was already running"}, nil
}
r, err := Restart(true)
if err != nil {
return ToggleResult{}, err
}
return ToggleResult{Compositing: len(r.PIDs) > 0, PIDs: r.PIDs, Note: r.Note}, nil
}
// Rule is one window rule, its options as written.
type Rule struct {
Match string `json:"match"`
Options map[string]string `json:"options"`
}
// RulesResult is what picom_rules answers.
type RulesResult struct {
File string `json:"file"`
Running []int `json:"running"`
Global map[string]string `json:"global"`
Rules []Rule `json:"rules"`
// Legacy names options that window rules supersede, which picom ignores when rules are set.
Legacy []string `json:"legacy,omitempty"`
}
var (
commentLine = regexp.MustCompile(`(?m)^\s*#.*$`)
rulesBlock = regexp.MustCompile(`(?s)\brules\s*[=:]\s*\((.*?)\)\s*;`)
ruleGroup = regexp.MustCompile(`(?s)\{(.*?)\}`)
assignment = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9-]*)\s*[=:]\s*(.+?)\s*;?\s*$`)
)
// superseded are the options picom's window rules replace (picom(1), WINDOW RULES).
var superseded = []string{"opacity-rule", "inactive-opacity", "active-opacity", "inactive-dim",
"shadow-exclude", "fade-exclude", "focus-exclude", "rounded-corners-exclude", "blur-background-exclude",
"corner-radius-rules", "wintypes", "inactive-opacity-override", "mark-wmwin-focused"}
// Rules reads picom's configuration file into its global options and its window rules.
func Rules(path string) (RulesResult, error) {
raw, err := os.ReadFile(path)
if err != nil {
return RulesResult{}, fmt.Errorf("picom's configuration: %w", err)
}
return parseRules(path, string(raw), running()), nil
}
func parseRules(path, conf string, pids []int) RulesResult {
out := RulesResult{File: path, Running: pids, Global: map[string]string{}, Rules: []Rule{}}
conf = commentLine.ReplaceAllString(conf, "")
rest := conf
if m := rulesBlock.FindStringSubmatchIndex(conf); m != nil {
body := conf[m[2]:m[3]]
rest = conf[:m[0]] + conf[m[1]:]
for _, g := range ruleGroup.FindAllStringSubmatch(body, -1) {
r := Rule{Options: map[string]string{}}
for _, part := range strings.Split(g[1], ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if k == "match" {
r.Match = unquote(v)
} else {
r.Options[k] = unquote(v)
}
}
out.Rules = append(out.Rules, r)
}
}
for _, m := range assignment.FindAllStringSubmatch(rest, -1) {
out.Global[m[1]] = unquote(strings.TrimSuffix(m[2], ";"))
}
if len(out.Rules) > 0 {
for _, name := range superseded {
if _, set := out.Global[name]; set {
out.Legacy = append(out.Legacy, name)
}
}
}
return out
}
func unquote(v string) string {
v = strings.TrimSpace(v)
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
return v[1 : len(v)-1]
}
return v
}
// OpacityResult is what picom_window_opacity answers.
type OpacityResult struct {
Window string `json:"window"`
Class string `json:"class,omitempty"`
Title string `json:"title,omitempty"`
// Opacity is the window's own value in percent; nil when it has none.
Opacity *int `json:"opacity"`
Note string `json:"note,omitempty"`
}
var (
activeWindow = regexp.MustCompile(`window id # (0x[0-9a-fA-F]+)`)
cardinal = regexp.MustCompile(`_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`)
wmClass = regexp.MustCompile(`WM_CLASS\(STRING\) = "[^"]*", "([^"]*)"`)
wmName = regexp.MustCompile(`_NET_WM_NAME\(UTF8_STRING\) = "(.*)"`)
)
// WindowOpacity reads a window's own opacity, and sets it when percent is 0-100.
func WindowOpacity(window string, percent int) (OpacityResult, error) {
s, err := findSession()
if err != nil {
return OpacityResult{}, err
}
if window == "" {
r, err := s.run(5*time.Second, "", "xprop", "-root", "_NET_ACTIVE_WINDOW")
if err != nil {
return OpacityResult{}, err
}
m := activeWindow.FindStringSubmatch(r.Stdout)
if m == nil || m[1] == "0x0" {
return OpacityResult{}, errors.New("no window is focused")
}
window = m[1]
}
id, err := windowID(window)
if err != nil {
return OpacityResult{}, err
}
if percent >= 0 {
var r Result
if percent == 100 {
r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-remove", "_NET_WM_WINDOW_OPACITY")
} else {
r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-f", "_NET_WM_WINDOW_OPACITY", "32c",
"-set", "_NET_WM_WINDOW_OPACITY", strconv.FormatUint(opacityCardinal(percent), 10))
}
if err != nil {
return OpacityResult{}, err
}
if r.Code != 0 {
return OpacityResult{}, fmt.Errorf("xprop: %s", strings.TrimSpace(r.Stderr))
}
}
r, err := s.run(5*time.Second, "", "xprop", "-id", id, "_NET_WM_WINDOW_OPACITY", "WM_CLASS", "_NET_WM_NAME")
if err != nil {
return OpacityResult{}, err
}
if r.Code != 0 {
return OpacityResult{}, fmt.Errorf("window %s: %s", id, strings.TrimSpace(r.Stderr))
}
out := parseWindow(id, r.Stdout)
if rules, err := Rules(configPath()); err == nil && out.Class != "" {
for _, rule := range rules.Rules {
if _, sets := rule.Options["opacity"]; sets && strings.Contains(rule.Match, "class_g = '"+out.Class+"'") {
out.Note = "a window rule sets the opacity of class " + out.Class + " and outranks the window's own value"
}
}
}
return out, nil
}
func windowID(w string) (string, error) {
w = strings.TrimSpace(strings.ToLower(w))
base := 10
if strings.HasPrefix(w, "0x") {
w, base = w[2:], 16
}
n, err := strconv.ParseUint(w, base, 32)
if err != nil || n == 0 {
return "", fmt.Errorf("window %q is not an X window id", w)
}
return fmt.Sprintf("0x%x", n), nil
}
func opacityCardinal(percent int) uint64 {
return uint64(math.Round(float64(percent) / 100 * float64(math.MaxUint32)))
}
func parseWindow(id, out string) OpacityResult {
r := OpacityResult{Window: id}
if m := cardinal.FindStringSubmatch(out); m != nil {
n, _ := strconv.ParseUint(m[1], 10, 64)
p := int(math.Round(float64(n) / float64(math.MaxUint32) * 100))
r.Opacity = &p
}
if m := wmClass.FindStringSubmatch(out); m != nil {
r.Class = m[1]
}
if m := wmName.FindStringSubmatch(out); m != nil {
r.Title = m[1]
}
return r
}