adding to git.i0t.app
This commit is contained in:
202
internal/compositor/compositor.go
Normal file
202
internal/compositor/compositor.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// 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`)
|
||||
}
|
||||
Reference in New Issue
Block a user