Files
mesh-catalog/modules/i3/cmd/i3-tools/tools.go
T
jochen 34829e39fe i3: the window manager holds node-display-session; its config improved and a checked reload watcher (hq ADR 0208)
It requires x11-display and contributes exec i3 to xinitrc's last slot, and
XDG_CURRENT_DESKTOP/XDG_SESSION_DESKTOP to the environment. It owns
~/.config/i3/config, ending with the config.d include where other modules drop
their files, and /etc/lemurs/wms/i3, which runs the session's start.

Over today's identical config it drops the dead lxpolkit, the D-Bus-activated
portal, the xrdb merge xorg now does, and the execs that XDG autostart already
started. It sets JetBrainsMono Nerd Font, runs i3-sensible-terminal, and declares
dex, which the desktop lacked.

The tools speak i3's IPC: the seat's reload, workspaces and windows, and focus,
move, layout save/restore, exec, kill, bindings, config check, marks and the
scratchpad. A watcher in the bundle (ADR 0198) replaces the predecessor's inotify
script and user unit. It reloads only a configuration i3 -C accepts, and at its
start whatever i3 has not loaded.
2026-10-04 13:21:47 +02:00

429 lines
12 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"i3/internal/desktop"
)
type i3 struct {
d desktop.Desk
socket func() (string, error)
configDir string
layouts string
watcher *Watcher
}
func (t *i3) ask(kind uint32, payload string, out any) error {
sock, err := t.socket()
if err != nil {
return err
}
return Ask(sock, kind, payload, out)
}
// command runs i3 commands and fails when any of them did.
func (t *i3) command(cmd string) ([]Outcome, error) {
var out []Outcome
if err := t.ask(RunCommand, cmd, &out); err != nil {
return nil, err
}
for _, o := range out {
if !o.Success {
return out, fmt.Errorf("i3 refused %q: %s", cmd, o.Error)
}
}
return out, nil
}
func (t *i3) tree() (Node, error) {
var root Node
err := t.ask(GetTree, "", &root)
return root, err
}
// check runs `i3 -C` on the configuration on disk. It needs no display.
func (t *i3) check(ctx context.Context) ([]ConfigError, error) {
res := t.d.Plain(ctx, "i3", "-C", "-c", filepath.Join(t.configDir, "config"))
if res.Code == 127 {
return nil, fmt.Errorf("i3 is not installed here")
}
problems := append([]ConfigError{}, ParseCheck(res.Stdout+"\n"+res.Stderr)...)
if !res.OK() && len(problems) == 0 {
problems = []ConfigError{{Text: strings.TrimSpace(res.Stdout + res.Stderr)}}
}
return problems, nil
}
func (t *i3) watcherStatus() any {
if t.watcher == nil {
return nil
}
return t.watcher.Status()
}
func (t *i3) reload(ctx context.Context, a desktop.Args) (any, error) {
force, _, err := a.Bool("force")
if err != nil {
return nil, err
}
problems, err := t.check(ctx)
if err != nil {
return nil, err
}
if len(problems) > 0 && !force {
return map[string]any{"reloaded": false, "errors": problems, "why": "the configuration on disk has errors; fix them, or force"}, nil
}
out, err := t.command("reload")
if err != nil {
return nil, err
}
return map[string]any{"reloaded": true, "i3": out, "errors": problems, "watcher": t.watcherStatus()}, nil
}
// WorkspaceInfo is one workspace as GET_WORKSPACES answers it.
type WorkspaceInfo struct {
Num int `json:"num"`
Name string `json:"name"`
Output string `json:"output"`
Visible bool `json:"visible"`
Focused bool `json:"focused"`
Urgent bool `json:"urgent"`
}
func (t *i3) workspaces(ctx context.Context, a desktop.Args) (any, error) {
var ws []WorkspaceInfo
if err := t.ask(GetWorkspaces, "", &ws); err != nil {
return nil, err
}
return map[string]any{"workspaces": ws}, nil
}
func (t *i3) windows(ctx context.Context, a desktop.Args) (any, error) {
root, err := t.tree()
if err != nil {
return nil, err
}
all := Windows(root)
ws := a.Opt("workspace", "")
out := []Window{}
for _, w := range all {
if ws == "" || w.Workspace == ws {
out = append(out, w)
}
}
return map[string]any{"windows": out}, nil
}
func (t *i3) focus(ctx context.Context, a desktop.Args) (any, error) {
crit, err := Criteria(a)
if err != nil {
return nil, err
}
var cmd string
switch ws := a.Opt("workspace", ""); {
case crit != "" && ws != "":
return nil, fmt.Errorf("a window or a workspace, not both")
case crit != "":
cmd = crit + " focus"
case ws != "":
cmd = "workspace " + Quote(ws)
default:
return nil, fmt.Errorf("name a window (con_id, window, class, instance, title, mark) or a workspace")
}
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
}
var direction = regexp.MustCompile(`^(left|right|up|down|current|primary|next|prev)$`)
func target(output string) string {
if direction.MatchString(output) {
return output
}
return Quote(output)
}
func (t *i3) move(ctx context.Context, a desktop.Args) (any, error) {
crit, err := Criteria(a)
if err != nil {
return nil, err
}
toWS, toOut, wsMove := a.Opt("to_workspace", ""), a.Opt("to_output", ""), a.Opt("workspace_to_output", "")
var cmd string
switch {
case wsMove != "":
if toOut == "" {
return nil, fmt.Errorf("workspace_to_output needs to_output")
}
cmd = "[workspace=" + Quote("^"+regexp.QuoteMeta(wsMove)+"$") + "] move workspace to output " + target(toOut)
case toWS != "" && toOut != "":
return nil, fmt.Errorf("to a workspace or to an output, not both")
case toWS != "":
cmd = strings.TrimSpace(crit + " move container to workspace " + Quote(toWS))
case toOut != "":
cmd = strings.TrimSpace(crit + " move container to output " + target(toOut))
default:
return nil, fmt.Errorf("name to_workspace or to_output")
}
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
}
var layoutName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
func (t *i3) layoutSave(ctx context.Context, a desktop.Args) (any, error) {
name, err := a.Text("name")
if err != nil {
return nil, err
}
if !layoutName.MatchString(name) {
return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore")
}
root, err := t.tree()
if err != nil {
return nil, err
}
ws, ok := Workspace(root, a.Opt("workspace", ""))
if !ok {
return nil, fmt.Errorf("no workspace %q", a.Opt("workspace", "(focused)"))
}
top, windows := Layout(ws)
if windows == 0 {
return nil, fmt.Errorf("workspace %s holds no windows to save", str(ws.Name))
}
if err := os.MkdirAll(t.layouts, 0o755); err != nil {
return nil, err
}
path := filepath.Join(t.layouts, name+".json")
if err := os.WriteFile(path, []byte(LayoutFile(str(ws.Name), time.Now().Format(time.RFC3339), top)), 0o644); err != nil {
return nil, err
}
return map[string]any{"name": name, "workspace": str(ws.Name), "windows": windows, "path": path}, nil
}
func (t *i3) savedLayouts() []map[string]string {
files, _ := filepath.Glob(filepath.Join(t.layouts, "*.json"))
sort.Strings(files)
out := []map[string]string{}
for _, f := range files {
b, _ := os.ReadFile(f)
out = append(out, map[string]string{"name": strings.TrimSuffix(filepath.Base(f), ".json"), "workspace": SavedWorkspace(string(b)), "path": f})
}
return out
}
func (t *i3) layoutRestore(ctx context.Context, a desktop.Args) (any, error) {
name, err := a.Text("name")
if err != nil {
return nil, err
}
if name == "list" {
return map[string]any{"layouts": t.savedLayouts()}, nil
}
if !layoutName.MatchString(name) {
return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore")
}
path := filepath.Join(t.layouts, name+".json")
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("no saved layout %q (i3_layout_restore with name list shows them)", name)
}
ws := a.Opt("workspace", SavedWorkspace(string(b)))
if ws == "" {
return nil, fmt.Errorf("name the workspace to lay it on")
}
cmd := "workspace " + Quote(ws) + "; append_layout " + Quote(path)
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"layout": name, "workspace": ws, "i3": out,
"then": "each placeholder swallows the next window of its class as it opens; start the programs (i3_exec) to fill them"}, nil
}
func (t *i3) exec(ctx context.Context, a desktop.Args) (any, error) {
command, err := a.Text("command")
if err != nil {
return nil, err
}
cmd := "exec --no-startup-id " + Quote(command)
if ws := a.Opt("workspace", ""); ws != "" {
cmd = "workspace " + Quote(ws) + "; " + cmd
}
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"started": command, "i3": out, "how": "a child of i3, in the session; i3 does not answer whether the program itself started"}, nil
}
func (t *i3) kill(ctx context.Context, a desktop.Args) (any, error) {
crit, err := Criteria(a)
if err != nil {
return nil, err
}
focused, _, err := a.Bool("focused")
if err != nil {
return nil, err
}
if crit == "" && !focused {
return nil, fmt.Errorf("name the windows to close, or focused: true")
}
force, _, _ := a.Bool("force")
cmd := strings.TrimSpace(crit + " kill")
if force {
cmd += " client"
}
before, _ := t.tree()
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out, "windows_before": len(Windows(before))}, nil
}
type configReply struct {
Config string `json:"config"`
Included []struct {
Path string `json:"path"`
Raw string `json:"raw_contents"`
Replaced string `json:"variable_replaced_contents"`
} `json:"included_configs"`
}
// loaded is each file the running i3 loaded, with the text it read.
func (t *i3) loaded() (map[string]string, error) {
var c configReply
if err := t.ask(GetConfig, "", &c); err != nil {
return nil, err
}
out := map[string]string{}
for _, inc := range c.Included {
out[inc.Path] = inc.Raw
}
if len(out) == 0 {
out[filepath.Join(t.configDir, "config")] = c.Config
}
return out, nil
}
func (t *i3) bindings(ctx context.Context, a desktop.Args) (any, error) {
var c configReply
if err := t.ask(GetConfig, "", &c); err != nil {
return nil, err
}
var all []Binding
if len(c.Included) == 0 {
all = Bindings("(main)", c.Config)
}
for _, inc := range c.Included {
all = append(all, Bindings(inc.Path, inc.Replaced)...)
}
match := strings.ToLower(a.Opt("match", ""))
out := []Binding{}
for _, b := range all {
if match == "" || strings.Contains(strings.ToLower(b.Keys+" "+b.Command), match) {
out = append(out, b)
}
}
return map[string]any{"bindings": out, "from": "the configuration i3 loaded at its last start or reload"}, nil
}
func (t *i3) configCheck(ctx context.Context, a desktop.Args) (any, error) {
problems, err := t.check(ctx)
if err != nil {
return nil, err
}
answer := map[string]any{"valid": len(problems) == 0, "errors": problems, "config": filepath.Join(t.configDir, "config"),
"on_disk": sortedKeys(Watched(t.configDir)), "watcher": t.watcherStatus()}
var v struct {
Loaded string `json:"loaded_config_file_name"`
Included []string `json:"included_config_file_names"`
Human string `json:"human_readable"`
}
if err := t.ask(GetVersion, "", &v); err == nil {
answer["running"] = map[string]any{"version": v.Human, "loaded": append([]string{v.Loaded}, v.Included...)}
}
return answer, nil
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func (t *i3) marks(ctx context.Context, a desktop.Args) (any, error) {
root, err := t.tree()
if err != nil {
return nil, err
}
type marked struct {
Mark string `json:"mark"`
Window Window `json:"window"`
}
out := []marked{}
for _, w := range Windows(root) {
for _, m := range w.Marks {
out = append(out, marked{m, w})
}
}
return map[string]any{"marks": out}, nil
}
func (t *i3) scratchpad(ctx context.Context, a desktop.Args) (any, error) {
action, err := a.OneOf("action", "list", "list", "show", "move")
if err != nil {
return nil, err
}
crit, err := Criteria(a)
if err != nil {
return nil, err
}
switch action {
case "list":
root, err := t.tree()
if err != nil {
return nil, err
}
out := []Window{}
for _, w := range Windows(root) {
if w.Scratchpad {
out = append(out, w)
}
}
return map[string]any{"scratchpad": out}, nil
case "show":
cmd := strings.TrimSpace(crit + " scratchpad show")
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
default:
cmd := strings.TrimSpace(crit + " move scratchpad")
out, err := t.command(cmd)
if err != nil {
return nil, err
}
return map[string]any{"command": cmd, "i3": out}, nil
}
}