// 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 }