242 lines
6.2 KiB
Go
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
|
|
}
|