Files

242 lines
6.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"encoding/json"
"errors"
"slices"
"strings"
)
type Kind uint8
const (
Mode Kind = iota
Workspace
Fullscreen
Floating
Urgent
MarkAdded
MarkRemoved
WorkspaceMove
)
// Event carries confirmed state, leaving wording and preferences to the app.
type Event struct {
Kind Kind
Name, Window, Workspace string
Focused, Enabled, Markup bool
FullscreenMode int
}
type node struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Window uint64 `json:"window"`
Focused bool `json:"focused"`
Urgent *bool `json:"urgent"`
Fullscreen *int `json:"fullscreen_mode"`
Scratchpad string `json:"scratchpad_state"`
Floating string `json:"floating"`
Marks []string `json:"marks"`
Properties struct {
Class string `json:"class"`
} `json:"window_properties"`
Nodes []node `json:"nodes"`
FloatingNodes []node `json:"floating_nodes"`
}
type workspace struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Focused bool `json:"focused"`
}
type location struct {
id uint64
name string
}
type nodeState struct {
Marks []string
Urgent *bool
}
type state struct {
nodes map[uint64]nodeState
locations map[uint64]location
focusedWorkspace uint64
}
func (s *state) baseline(root node, spaces []workspace) {
s.nodes = make(map[uint64]nodeState)
s.locations = make(map[uint64]location)
s.walk(root, location{}, true)
for _, ws := range spaces {
if ws.Focused {
s.focusedWorkspace = ws.ID
}
}
}
func (s *state) walk(n node, ws location, baseline bool) {
if n.Type == "workspace" {
ws = location{n.ID, n.Name}
}
if n.ID != 0 {
if baseline {
s.nodes[n.ID] = nodeState{Marks: n.Marks, Urgent: n.Urgent}
}
// i3's private scratch workspace is deliberately outside this feature.
if ws.id != 0 && ws.name != "__i3_scratch" {
s.locations[n.ID] = ws
}
}
for _, children := range [][]node{n.Nodes, n.FloatingNodes} {
for _, child := range children {
s.walk(child, ws, baseline)
}
}
}
func (s *state) refreshLocations(root node) {
s.locations = make(map[uint64]location)
s.walk(root, location{}, false)
}
func windowName(n node) string {
if n.Name != "" {
return n.Name
}
return n.Properties.Class
}
func (s *state) apply(m message, queryTree func() (node, error)) ([]Event, error) {
var payload struct {
Change string `json:"change"`
Markup bool `json:"pango_markup"`
Current *node `json:"current"`
Container *node `json:"container"`
}
if err := json.Unmarshal(m.body, &payload); err != nil {
return nil, err
}
if m.kind == modeEvent {
if payload.Change == "" {
return nil, nil
}
return []Event{{Kind: Mode, Name: payload.Change, Markup: payload.Markup}}, nil
}
if m.kind == workspaceEvent {
n := payload.Current
if n == nil || n.ID == 0 {
return nil, nil
}
switch payload.Change {
case "focus":
s.focusedWorkspace = n.ID
return []Event{{Kind: Workspace, Name: n.Name}}, nil
case "rename":
for id, ws := range s.locations {
if ws.id == n.ID {
ws.name = n.Name
s.locations[id] = ws
}
}
if n.ID == s.focusedWorkspace {
return []Event{{Kind: Workspace, Name: n.Name}}, nil
}
}
// Workspace urgency is aggregate state, already handled at the window.
return nil, nil
}
if m.kind != windowEvent || payload.Container == nil || payload.Container.ID == 0 {
return nil, nil
}
n := *payload.Container
old, known := s.nodes[n.ID]
previous := s.locations[n.ID]
if payload.Change == "close" {
delete(s.nodes, n.ID)
delete(s.locations, n.ID)
return nil, nil
}
s.nodes[n.ID] = nodeState{Marks: n.Marks, Urgent: n.Urgent}
if len(s.nodes) > 65536 {
return nil, errors.New("i3 comparison cache exceeded")
}
if payload.Change == "new" || payload.Change == "move" || payload.Change == "urgent" || payload.Change == "floating" {
root, err := queryTree()
if err != nil {
return nil, err
}
s.refreshLocations(root)
}
base := Event{Window: windowName(n), Focused: n.Focused, Workspace: s.locations[n.ID].name}
switch payload.Change {
case "fullscreen_mode":
if n.Fullscreen != nil && *n.Fullscreen >= 0 && *n.Fullscreen <= 2 {
base.Kind, base.Enabled, base.FullscreenMode = Fullscreen, *n.Fullscreen != 0, *n.Fullscreen
return []Event{base}, nil
}
case "floating":
// Sending a tiled window to the scratchpad also enables floating.
// That implicit transition belongs to the excluded scratchpad feature.
// Its event can precede assignment, so require a confirmed public
// workspace in the resulting tree as well as the reported state.
if s.locations[n.ID].id == 0 || n.Scratchpad != "" && n.Scratchpad != "none" {
return nil, nil
}
switch n.Floating {
case "auto_on", "user_on", "auto_off", "user_off":
base.Kind, base.Enabled = Floating, strings.HasSuffix(n.Floating, "_on")
return []Event{base}, nil
}
case "urgent":
if n.Urgent != nil && *n.Urgent && (!known || old.Urgent == nil || !*old.Urgent) {
base.Kind = Urgent
return []Event{base}, nil
}
case "mark":
if !known || old.Marks == nil || n.Marks == nil {
return nil, nil
}
var events []Event
// Stable order even if i3 reorders an unchanged set.
for _, diff := range []struct {
before, after []string
kind Kind
}{{n.Marks, old.Marks, MarkRemoved}, {old.Marks, n.Marks, MarkAdded}} {
before := make(map[string]struct{}, len(diff.before))
for _, name := range diff.before {
before[name] = struct{}{}
}
names := slices.Clone(diff.after)
slices.Sort(names)
names = slices.Compact(names)
for _, name := range names {
if _, exists := before[name]; !exists {
e := base
e.Kind, e.Name = diff.kind, name
if len(events) >= maxPending {
return nil, errors.New("i3 mark changes exceeded")
}
events = append(events, e)
}
}
}
return events, nil
case "move":
current := s.locations[n.ID]
if n.Window != 0 && previous.id != 0 && current.id != 0 && previous.id != current.id {
base.Kind = WorkspaceMove
return []Event{base}, nil
}
}
return nil, nil
}