// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "encoding/xml" "io" "strings" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/i3ipc" "git.stormux.org/storm/cthulhu/internal/lifecycle" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) // i3Announcements owns presentation policy, never focus or accessibility state. // The backend owns its connection, baseline, caches and reconnect lifetime. type i3Announcements struct { live *liveSettings output speechEnqueuer } func i3ModeName(name string, markup bool) string { if !markup { return strings.TrimSpace(name) } decoder := xml.NewDecoder(strings.NewReader("" + name + "")) var text strings.Builder for { token, err := decoder.Token() if err == io.EOF { break } if err != nil { return "" } if data, ok := token.(xml.CharData); ok { text.Write(data) } } return strings.TrimSpace(text.String()) } func i3Message(event i3ipc.Event, settings config.ReadingSettings) string { var text string switch event.Kind { case i3ipc.Mode: if !settings.I3Modes { return "" } name := i3ModeName(event.Name, event.Markup) if name == "" { return "" } if name == "default" { return "Default mode" } return name + " mode" case i3ipc.Workspace: if settings.I3Workspaces && event.Name != "" { return "Workspace " + event.Name } return "" case i3ipc.Fullscreen: if !settings.I3WindowStates { return "" } text = "Fullscreen off" if event.Enabled { text = "Fullscreen on" if event.FullscreenMode == 2 { text = "Global fullscreen on" } } case i3ipc.Floating: if !settings.I3WindowStates { return "" } text = "Floating off" if event.Enabled { text = "Floating on" } case i3ipc.Urgent: if !settings.I3Urgency { return "" } text = "Urgent" if event.Window != "" { text += ": " + event.Window } if event.Workspace != "" { text += ", workspace " + event.Workspace } return text case i3ipc.MarkAdded, i3ipc.MarkRemoved: if !settings.I3Marks { return "" } text = "Mark added: " + event.Name if event.Kind == i3ipc.MarkRemoved { text = "Mark removed: " + event.Name } case i3ipc.WorkspaceMove: if !settings.I3WorkspaceMoves || event.Workspace == "" { return "" } text = "Moved to workspace " + event.Workspace default: return "" } if !event.Focused { name := event.Window if name == "" { name = "Window" } text = name + ": " + text } return text } func (controller i3Announcements) present(event i3ipc.Event) { settings := controller.live.Load() text := i3Message(event, settings.Reading) if text == "" { return } controller.output.Enqueue(speechRequest{Text: text, Role: "system", Cause: "i3-desktop", Append: true, BypassFocusGate: true, InterruptDuringHandoff: true}) } func i3Service(controller i3Announcements) lifecycle.Service { var cancel context.CancelFunc var done chan struct{} return lifecycle.Service{Name: "i3 desktop", Start: func(ctx context.Context) error { ctx, cancel = context.WithCancel(ctx) done = make(chan struct{}) go func() { defer close(done); i3ipc.Run(ctx, xorg.I3Socket, controller.present) }() return nil }, Stop: func(ctx context.Context) error { if cancel == nil { return nil } cancel() select { case <-done: return nil case <-ctx.Done(): return ctx.Err() } }} }