// 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" "time" ) // Rect is a pixel rectangle on the output. type Rect struct { X, Y, W, H int } // 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"` 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) } } // hasPID reports whether a window with the given pid currently exists. func (c *Client) hasPID(ctx context.Context, pid int) (bool, error) { out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output() if err != nil { return false, fmt.Errorf("get_tree: %w", err) } var root treeNode if err := json.Unmarshal(out, &root); err != nil { return false, err } found := false root.walk(func(n *treeNode) { if n.PID == pid && (n.AppID != "" || n.Name != "") { found = true } }) return found, nil } // WaitForWindow blocks until a window owned by pid maps, or ctx/timeout fires. func (c *Client) WaitForWindow(ctx context.Context, pid int, timeout time.Duration) error { deadline := time.Now().Add(timeout) ticker := time.NewTicker(150 * time.Millisecond) defer ticker.Stop() for { ok, err := c.hasPID(ctx, pid) if err == nil && ok { return nil } select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: if time.Now().After(deadline) { return fmt.Errorf("window for pid %d did not appear within %s", pid, timeout) } } } } // 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 global rules so every mpv window is borderless and // floating the moment it maps, avoiding a flash of tiled/bordered video before // Place runs. Safe to call repeatedly. func (c *Client) PrepareForMPV(ctx context.Context) error { return c.run(ctx, `for_window [app_id="mpv"] floating enable, border none`) }