// Package compositor drives a running sway session to tile mpv windows into a // grid. The daemon does NOT launch sway; the standard kiosk pattern is for // sway (started at boot via autologin) to exec the daemon, so SWAYSOCK and // WAYLAND_DISPLAY are inherited. This package shells out to swaymsg, which is // always present alongside sway and speaks the sway IPC for us. package compositor import ( "context" "encoding/json" "fmt" "os/exec" ) // Rect is a pixel rectangle on the output. The JSON tags match sway's // get_tree/get_outputs "rect" objects so it can be decoded directly. type Rect struct { X int `json:"x"` Y int `json:"y"` W int `json:"width"` H int `json:"height"` } // GridRects splits a WxH area into cols*rows cells in row-major order. The // final column and row absorb any rounding remainder so there are no gaps. func GridRects(width, height, cols, rows int) []Rect { rects := make([]Rect, 0, cols*rows) for r := 0; r < rows; r++ { for c := 0; c < cols; c++ { x := c * width / cols y := r * height / rows // Right/bottom edge computed from the next cell boundary to avoid // cumulative rounding gaps. x2 := (c + 1) * width / cols y2 := (r + 1) * height / rows if c == cols-1 { x2 = width } if r == rows-1 { y2 = height } rects = append(rects, Rect{X: x, Y: y, W: x2 - x, H: y2 - y}) } } return rects } // TileRect computes the pixel rectangle for a tile spanning (colspan x rowspan) // cells starting at (col,row) in a cols x rows base grid over a WxH output. // Boundaries are computed from cell edges so adjacent tiles meet exactly and // the far edges reach the full width/height with no rounding gaps. func TileRect(width, height, cols, rows, col, row, colspan, rowspan int) Rect { x := col * width / cols y := row * height / rows x2 := (col + colspan) * width / cols y2 := (row + rowspan) * height / rows if col+colspan >= cols { x2 = width } if row+rowspan >= rows { y2 = height } return Rect{X: x, Y: y, W: x2 - x, H: y2 - y} } // Client talks to sway via swaymsg. type Client struct { bin string } // New returns a compositor client. It verifies swaymsg is on PATH. func New() (*Client, error) { bin, err := exec.LookPath("swaymsg") if err != nil { return nil, fmt.Errorf("swaymsg not found on PATH: %w", err) } return &Client{bin: bin}, nil } // run executes a raw sway command string. func (c *Client) run(ctx context.Context, command string) error { out, err := exec.CommandContext(ctx, c.bin, command).CombinedOutput() if err != nil { return fmt.Errorf("swaymsg %q: %w: %s", command, err, out) } return nil } // Output describes a connected display from `swaymsg -t get_outputs`. type Output struct { Name string `json:"name"` Active bool `json:"active"` Focused bool `json:"focused"` CurrentMode struct { Width int `json:"width"` Height int `json:"height"` } `json:"current_mode"` Rect Rect `json:"-"` } // PrimaryOutput returns the focused active output (or the first active one), // used to auto-detect resolution when the config doesn't pin it. func (c *Client) PrimaryOutput(ctx context.Context) (*Output, error) { out, err := exec.CommandContext(ctx, c.bin, "-t", "get_outputs", "-r").Output() if err != nil { return nil, fmt.Errorf("get_outputs: %w", err) } var outputs []Output if err := json.Unmarshal(out, &outputs); err != nil { return nil, err } var first *Output for i := range outputs { o := &outputs[i] if !o.Active { continue } if first == nil { first = o } if o.Focused { return o, nil } } if first == nil { return nil, fmt.Errorf("no active output found") } return first, nil } // treeNode is the recursive shape of `swaymsg -t get_tree`. type treeNode struct { PID int `json:"pid"` Name string `json:"name"` AppID string `json:"app_id"` Rect Rect `json:"rect"` Nodes []treeNode `json:"nodes"` Float []treeNode `json:"floating_nodes"` } func (n *treeNode) walk(fn func(*treeNode)) { fn(n) for i := range n.Nodes { n.Nodes[i].walk(fn) } for i := range n.Float { n.Float[i].walk(fn) } } // WindowRects returns the geometry of every mapped window keyed by owning // pid, from a single get_tree round trip. The daemon checks all tiles against // one snapshot instead of issuing a swaymsg per window. func (c *Client) WindowRects(ctx context.Context) (map[int]Rect, error) { out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output() if err != nil { return nil, fmt.Errorf("get_tree: %w", err) } var root treeNode if err := json.Unmarshal(out, &root); err != nil { return nil, err } rects := map[int]Rect{} root.walk(func(n *treeNode) { if n.PID != 0 && (n.AppID != "" || n.Name != "") { rects[n.PID] = n.Rect } }) return rects, nil } // Place floats and positions the window owned by pid at the given rect. Using // the pid criterion means we never depend on window titles or app-ids. func (c *Client) Place(ctx context.Context, pid int, r Rect) error { cmd := fmt.Sprintf( "[pid=%d] floating enable, border none, move absolute position %d %d, resize set %d %d", pid, r.X, r.Y, r.W, r.H, ) return c.run(ctx, cmd) } // PrepareForMPV installs a global rule so every mpv window maps floating, // ready for the daemon to position. Borders are already off via the kiosk // config's `default_border none`, so this rule is a single command: sway's // IPC parser splits a comma-joined command list at the top level (it does NOT // fold the continuation into for_window the way the config-file parser does), // so a multi-command for_window must be registered one command per call. // Safe to call repeatedly. func (c *Client) PrepareForMPV(ctx context.Context) error { return c.run(ctx, `for_window [app_id="mpv"] floating enable`) } // PlaceOnMap installs for_window rules that position and size any window with // the given title the moment it maps — so a restarting stream lands in its // cell without a wrong-place flash, before the corrective loop ever runs. The // title is anchored (^...$) so slot-1 never matches slot-10. Because sway's // IPC command parser splits on commas at the top level, each rule carries a // single command and is registered separately (a comma-joined for_window would // silently drop everything after the first command and run the rest // immediately against the focused container). Re-registering a rule for the // same criteria replaces it. func (c *Client) PlaceOnMap(ctx context.Context, title string, r Rect) error { crit := fmt.Sprintf(`[title="^%s$"]`, title) cmds := []string{ fmt.Sprintf("for_window %s floating enable", crit), fmt.Sprintf("for_window %s move absolute position %d %d", crit, r.X, r.Y), fmt.Sprintf("for_window %s resize set %d %d", crit, r.W, r.H), } for _, cmd := range cmds { if err := c.run(ctx, cmd); err != nil { return err } } return nil }