Add native i3 desktop announcements

This commit is contained in:
ak47 committed 2026-11-07 14:46:46 -05:00
1 parent 49eeae89f4
commit bf1f155018
22 files changed
+1835 -1

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ Cthulhu is a Linux screen reader written in Go.
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
- [Browser Find](docs/testing/browser-find.md): native Ctrl+F focus, typing, window ownership and verification.
- [Native i3 announcements](docs/testing/i3-announcements.md): modes, workspaces, window states, urgency, marks, workspace moves, and live verification.
- [MATE window switcher](docs/testing/window-switcher.md): Marco selection announcements, isolated checks, and live verification.
- [MATE desktop](docs/testing/mate-desktop.md): Orca comparison, panel menus and icon labels, regression checks, and live verification.
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
+132
View File
@@ -0,0 +1,132 @@
# Native i3 announcements
Cthulhu observes the active i3 X11 session directly over its Unix IPC socket,
using the [i3 IPC specification](https://i3wm.org/docs/ipc.html).
There is no Python runtime helper, new binding, sound, or window-management
command. Other desktops keep their existing support. The installed i3 executable
and an inherited `I3SOCK` variable do not enable support: the active window
manager must identify itself as i3, and its root window must advertise its socket.
The Reading settings table provides six choices, all enabled by default:
- Announce i3 binding modes: the configured name followed by “mode”; returning
to the default binding mode says “Default mode”. Pango markup is converted
to text when i3 flags it as markup.
- Announce i3 workspaces: “Workspace {name}” on focus, including empty workspaces
and focused-workspace renames.
- Announce i3 window states: fullscreen on/off, global fullscreen on, and
floating on/off. Background changes include the window title or class.
- Announce i3 urgent windows: the window and workspace when available. Aggregate
workspace urgency does not produce a second message.
- Announce i3 marks: additions and removals compared with the previously
reported mark set.
- Announce i3 window workspace moves: the destination, only when previous and
current workspace identities are known. Rearrangement within a workspace stays
silent. Rapid events may be consolidated to the confirmed destination.
These choices persist in the versioned TOML profile under
`presentation.reading` as `i3-modes`, `i3-workspaces`, `i3-window-states`,
`i3-urgency`, `i3-marks`, and `i3-workspace-moves`. Apply/reload and application
profiles use the existing settings path. Global preferences are usually the most
convenient place to set desktop announcement choices.
Announcements queue with other incoming speech; normal user-input interruption
still applies. They can speak during Fenrir terminal handoff. Screen lock,
internal reader UI, speech disable, speech mode, and braille flash preferences
retain their existing policies. Suppressed events are discarded. Braille uses
the existing flash duration and restores the current application frame without
moving focus. Existing persistent-flash settings still apply.
I38 may announce some of the same actions independently. Cthulhu does not edit
I38 configuration. If both announce a workspace or mode, choose which source you
want to hear and disable the corresponding announcements in that source.
Scratchpad visibility/cycling, automatic layout changes, window creation and
closure, and extra window introductions are excluded. Sending a tiled window to
the scratchpad implicitly enables floating; that scratchpad state change is
suppressed too. Binding commands are never used to guess state. Startup and
reconnection establish silent baselines. Missing context skips comparisons;
malformed messages, timeouts, or excessive pending events discard the connection
and retry discovery with bounded backoff.
## Automated verification
Run from the repository root:
```sh
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/i3ipc ./internal/config ./internal/preferences -count=1
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./internal/app -run 'TestI3|TestAnnouncement|TestBrailleFlash|TestProcessInputEventsUsesLiveInterruption|TestTitlePreference' -count=1 -timeout=60s
python3 tests/native_i3.py
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go build -o /tmp/cthulhu-i3-reader ./cmd/cthulhu
CTHULHU_BINARY=/tmp/cthulhu-i3-reader CTHULHU_PREFS_READING_ONLY=1 python3 tests/native_preferences.py
python3 tests/check_speech.py
```
The native harness uses a private 24-bit Xvfb display and the shared disposable
D-Bus session launcher. It checks actual mode, markup, empty workspace, rename,
fullscreen, floating, mark, same-workspace move, cross-workspace move, and urgency
events, plus background window identity and scratchpad silence. It also verifies
that a private X display without i3 does not activate support. Tests do not
replace or control the running reader or desktop WM.
## Live speech, keyboard, and braille acceptance
Human diff review and live acceptance are required before submission. Automated
checks establish queue requests and braille frames, not audible output or a
physical braille display. Preserve diagnostic logs if investigating a failure.
Use an expendable window and named workspaces for move and mark checks.
1. Quit the current reader with Cthulhu+Q. Press Ctrl+Escape to enter i3 personal
mode, release those keys, then press Shift+G. Ctrl+Escape and Shift+G are
separate key presses. This runs `~/devel/cthulhu-go.sh`, rebuilding the reader
and braille helper, starting the debug log, and opening the test terminal.
Expect no announcement of the existing mode/workspace merely from startup.
2. In global Preferences, find the six “Announce i3” rows in Reading. Check
their setting names, descriptions, and checked states. Use arrows between
columns and rows, Space to toggle, and Tab/Shift+Tab into and out of the
table. Apply, close, and reopen. Expect saved values and usable keyboard
focus. Restore all six to enabled for the following checks. For braille
restoration checks, enable flash messages with a finite duration and turn
persistent flashes off.
3. Enter each configured personal, ratpoison, and panel mode using your existing
bindings, returning to default between them. Expect the reported name plus
“mode”, and “Default mode” on return. A configured markup mode should read
its text without tags. Expect the same text briefly on braille, followed by
current application content. Focus and ordinary typing must remain usable.
4. Focus an empty named workspace, for example using
`i3-msg 'workspace "cthulhu test empty"'`. Expect “Workspace cthulhu test
empty”. Rename it with `i3-msg 'rename workspace to "cthulhu test renamed"'`;
expect “Workspace cthulhu test renamed”. Switching back should announce the
destination workspace. Existing application-focus and title speech should
still occur normally, with no new i3 window introduction.
5. In the expendable window, use existing bindings to enable/disable fullscreen
and floating. Expect “Fullscreen on/off” and “Floating on/off”, with braille
flashes and no extra focus change. Add and remove a temporary mark with
`i3-msg 'mark "cthulhu test"'` and `i3-msg 'unmark "cthulhu test"'`.
Expect “Mark added: cthulhu test” and “Mark removed: cthulhu test”.
6. Move the expendable window to a different workspace using your usual binding.
Expect “Moved to workspace {destination}”, with a title prefix if the event
reports a background window. Rearrange it within that workspace; expect no
workspace-move announcement. An actual urgent background window should
produce one “Urgent: {window}, workspace {name}” announcement. Record any
unavailable urgency case as untested. Scratchpad show/hide/cycling should
add no scratchpad announcement.
7. Return to the Fenrir test terminal. Enter and leave an i3 mode and switch
workspaces. Expect Cthulhu's desktop announcements while Fenrir continues
handling terminal content and keys. Leave the terminal for an accessible
application; expect normal Cthulhu application focus, reading keys, title
changes, and braille to resume.
8. Disable one i3 Reading choice, Apply, and exercise its action. Expect that
category silent in Cthulhu; re-enable it and verify new events speak without
replaying the suppressed events. Check speech disable/mode behavior using
existing reader settings. While the screen is locked or internal reader UI
is active, background i3 changes should not speak or flash, and should not
replay after leaving that state. Use only transitions reachable in those
states; do not bypass the lock to create a test event.
9. Trigger several desktop changes while other speech is queued. Expect incoming
messages to queue without cutting off incoming speech. Fresh user input
should still interrupt according to your configured interruption keys.
Distinguish a key's normal interruption from a new announcement arriving.
10. Report the exact speech, braille text and restoration, focused control/window,
keys used, and any independent I38 duplicate. If anything differs, inspect
`~/devel/cthulhu-go.log` before restarting again.
+3
View File
@@ -252,6 +252,9 @@ func Run(ctx context.Context, options Options) error {
live.editFieldFocused = source.EditFieldFocused
}
live.Store(settings)
services = append(services, i3Service(i3Announcements{live: live, output: gatedSpeech{
gate: focusGate, speech: outputQueue{speech: speechOutput, braille: brailleOutput, live: live},
}}))
if browser != nil {
browser.settings = func() review.ModeSettings {
settings := review.DefaultModeSettings()
+153
View File
@@ -0,0 +1,153 @@
// 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("<mode>" + name + "</mode>"))
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})
}
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()
}
}}
}
+180
View File
@@ -0,0 +1,180 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"path/filepath"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/i3ipc"
"git.stormux.org/storm/cthulhu/internal/preferences"
)
func TestI3MessageWording(t *testing.T) {
settings := config.DefaultSettings().Reading
for _, tc := range []struct {
event i3ipc.Event
want string
}{
{i3ipc.Event{Kind: i3ipc.Mode, Name: "default"}, "Default mode"},
{i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"}, "personal mode"},
{i3ipc.Event{Kind: i3ipc.Mode, Name: "ratpoison"}, "ratpoison mode"},
{i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"}, "panel mode"},
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<b>résumé &amp; keys</b>", Markup: true}, "résumé & keys mode"},
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<raw & name>"}, "<raw & name> mode"},
{i3ipc.Event{Kind: i3ipc.Mode, Name: "<broken>", Markup: true}, ""},
{i3ipc.Event{Kind: i3ipc.Workspace, Name: "99: arbitrary & empty"}, "Workspace 99: arbitrary & empty"},
{i3ipc.Event{Kind: i3ipc.Fullscreen, Focused: true, Enabled: true}, "Fullscreen on"},
{i3ipc.Event{Kind: i3ipc.Fullscreen, Window: "Browser"}, "Browser: Fullscreen off"},
{i3ipc.Event{Kind: i3ipc.Fullscreen, Focused: true, Enabled: true, FullscreenMode: 2}, "Global fullscreen on"},
{i3ipc.Event{Kind: i3ipc.Floating, Focused: true, Enabled: true}, "Floating on"},
{i3ipc.Event{Kind: i3ipc.Floating, Window: "Editor"}, "Editor: Floating off"},
{i3ipc.Event{Kind: i3ipc.Urgent, Window: "Mail", Workspace: "communications"}, "Urgent: Mail, workspace communications"},
{i3ipc.Event{Kind: i3ipc.Urgent}, "Urgent"},
{i3ipc.Event{Kind: i3ipc.MarkAdded, Name: "bookmark", Focused: true}, "Mark added: bookmark"},
{i3ipc.Event{Kind: i3ipc.MarkRemoved, Name: "bookmark", Window: "Editor"}, "Editor: Mark removed: bookmark"},
{i3ipc.Event{Kind: i3ipc.WorkspaceMove, Workspace: "Notes", Focused: true}, "Moved to workspace Notes"},
} {
t.Run(tc.want, func(t *testing.T) {
if got := i3Message(tc.event, settings); got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
func TestI3QueueHandoffAndSuppression(t *testing.T) {
live := &liveSettings{}
settings := config.DefaultSettings()
live.Store(settings)
queue := &remoteSpeechQueue{}
display := &flashRecorder{}
gate := newFocusGate(nil)
controller := i3Announcements{live: live, output: gatedSpeech{gate: gate, speech: outputQueue{speech: queue, braille: display, live: live}}}
gate.suspended.Store(true)
for _, name := range []string{"personal", "panel", "default"} {
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: name})
}
if len(queue.requests) != 3 || len(display.messages) != 3 {
t.Fatalf("handoff: %+v %+v", queue.requests, display.messages)
}
for i, request := range queue.requests {
if !request.Append || !request.BypassFocusGate || request.Role != "system" || request.Text != display.messages[i] {
t.Fatal(request)
}
}
gate.secure.Store(true)
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "locked"})
gate.secure.Store(false)
ui := &fakeInternalUIState{active: true}
gate.internalUI = ui
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "internal"})
ui.active = false
settings.Reading.I3Modes = false
live.Store(settings)
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "disabled"})
settings.Reading.I3Modes = true
live.Store(settings)
if len(queue.requests) != 3 || len(display.messages) != 3 {
t.Fatal("suppressed output was presented")
}
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "resumed"})
if len(queue.requests) != 4 || queue.requests[3].Text != "resumed mode" {
t.Fatal(queue.requests)
}
}
type fakeInternalUIState struct{ active bool }
func (ui *fakeInternalUIState) InternalUIActive() bool { return ui.active }
func TestI3PreferencesRuntimeAndPersistence(t *testing.T) {
settings := config.DefaultSettings()
settings.Path = filepath.Join(t.TempDir(), "settings.toml")
store, err := config.NewStore(settings.Path)
if err != nil {
t.Fatal(err)
}
if err = store.Save(settings); err != nil {
t.Fatal(err)
}
worker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, nil)
worker.live = &liveSettings{}
worker.live.Store(settings)
worker.beginEdit(context.Background(), "")
values := preferences.FromSettings(worker.editing)
values.Reading.I3Modes = false
values.Reading.I3Workspaces = false
values.Reading.I3WindowStates = false
values.Reading.I3Urgency = false
values.Reading.I3Marks = false
values.Reading.I3WorkspaceMoves = false
if response := worker.apply(context.Background(), preferences.Request{Values: values, Save: true}); !response.Accepted {
t.Fatal(response.Message)
}
loaded, err := config.Load(settings.Path)
if err != nil {
t.Fatal(err)
}
for _, reading := range []config.ReadingSettings{loaded.Reading, worker.live.Load().Reading} {
for _, kind := range []i3ipc.Kind{i3ipc.Mode, i3ipc.Workspace, i3ipc.Fullscreen, i3ipc.Floating, i3ipc.Urgent, i3ipc.MarkAdded, i3ipc.MarkRemoved, i3ipc.WorkspaceMove} {
if got := i3Message(i3ipc.Event{Kind: kind, Name: "test", Workspace: "B"}, reading); got != "" {
t.Fatalf("disabled preference presented %q", got)
}
}
}
}
func TestI3FlashRestoresApplicationContent(t *testing.T) {
worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true}
live := &liveSettings{}
settings := config.DefaultSettings()
settings.Braille.FlashTime = 20
settings.Braille.FlashPersistent = false
live.Store(settings)
worker.EnqueueFrame(braille.Frame{Text: "Application content", Cursor: 2})
controller := i3Announcements{live: live, output: outputQueue{speech: &remoteSpeechQueue{}, braille: worker, live: live}}
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"})
if got := worker.current(); got.Text != "panel mode" {
t.Fatal(got)
}
deadline := time.Now().Add(time.Second)
for worker.current().Text != "Application content" && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if got := worker.current(); got.Text != "Application content" || got.Cursor != 2 {
t.Fatal(got)
}
}
func TestI3UsesSpeechFIFOInterruptionAndDisablePolicy(t *testing.T) {
live := &liveSettings{}
live.Store(config.DefaultSettings())
speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
// Hold the delivery worker so admission order and cancellation are observable.
speech.running = true
controller := i3Announcements{live: live, output: outputQueue{speech: speech, live: live}}
speech.Enqueue(speechRequest{Text: "Application output"})
generation := speech.generation
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"})
controller.present(i3ipc.Event{Kind: i3ipc.Workspace, Name: "Notes"})
if len(speech.pending) != 3 || speech.pending[0].Text != "Application output" || speech.pending[1].Text != "personal mode" || speech.pending[2].Text != "Workspace Notes" || speech.generation != generation {
t.Fatalf("incoming events changed FIFO or interrupted: %+v", speech.pending)
}
speech.InterruptForUser()
if len(speech.pending) != 0 {
t.Fatal("user interruption did not clear desktop messages")
}
speech.SetEnabled(false)
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "suppressed"})
speech.SetEnabled(true)
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "new"})
if len(speech.pending) != 1 || speech.pending[0].Text != "new mode" {
t.Fatalf("disabled events replayed: %+v", speech.pending)
}
}
+6
View File
@@ -39,6 +39,12 @@ func DefaultSettings() Settings {
SayAllStyle: "line",
TextAttributes: DefaultTextAttributes(),
Reading: ReadingSettings{
I3Modes: true,
I3Workspaces: true,
I3WindowStates: true,
I3Urgency: true,
I3Marks: true,
I3WorkspaceMoves: true,
AutomaticallyReadTitleChanges: true,
EnablePauseBreaks: true,
SpeakNumbersAsDigits: false,
+73
View File
@@ -0,0 +1,73 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package config
import (
"path/filepath"
"testing"
)
func i3Flags(r ReadingSettings) []bool {
return []bool{r.I3Modes, r.I3Workspaces, r.I3WindowStates, r.I3Urgency, r.I3Marks, r.I3WorkspaceMoves}
}
func TestI3DefaultsPersistenceAndInheritance(t *testing.T) {
old, err := Load(filepath.Join("testdata", "settings-v2.toml"))
if err != nil {
t.Fatal(err)
}
for _, v := range i3Flags(old.Reading) {
if !v {
t.Fatal("existing configuration defaults i3 announcements off")
}
}
store, err := NewStore(filepath.Join(t.TempDir(), "settings.toml"))
if err != nil {
t.Fatal(err)
}
global := DefaultSettings()
if err = store.Save(global); err != nil {
t.Fatal(err)
}
app := global
app.Reading.I3Modes = false
app.Reading.I3Workspaces = false
app.Reading.I3WindowStates = false
app.Reading.I3Urgency = false
app.Reading.I3Marks = false
app.Reading.I3WorkspaceMoves = false
if err = store.SaveForApplication("Quiet App", app); err != nil {
t.Fatal(err)
}
loaded, found, err := store.LoadForApplication("Quiet App")
if err != nil || !found {
t.Fatalf("%t %v", found, err)
}
for _, v := range i3Flags(loaded.Reading) {
if v {
t.Fatal("override lost")
}
}
inherited, _, err := store.LoadForApplication("Other App")
if err != nil {
t.Fatal(err)
}
for _, v := range i3Flags(inherited.Reading) {
if !v {
t.Fatal("override leaked")
}
}
global.Reading = app.Reading
if err = store.Save(global); err != nil {
t.Fatal(err)
}
inherited, _, err = store.LoadForApplication("Other App")
if err != nil {
t.Fatal(err)
}
for _, v := range i3Flags(inherited.Reading) {
if v {
t.Fatal("global update not inherited")
}
}
}
+24
View File
@@ -76,6 +76,12 @@ func profileFromSettings(settings Settings) writableProfile {
SayAllStyle: stringPointer(settings.SayAllStyle),
Reading: writableReading{
AutomaticallyReadClipboardChanges: boolPointer(settings.Reading.AutomaticallyReadClipboardChanges),
I3Modes: boolPointer(settings.Reading.I3Modes),
I3Workspaces: boolPointer(settings.Reading.I3Workspaces),
I3WindowStates: boolPointer(settings.Reading.I3WindowStates),
I3Urgency: boolPointer(settings.Reading.I3Urgency),
I3Marks: boolPointer(settings.Reading.I3Marks),
I3WorkspaceMoves: boolPointer(settings.Reading.I3WorkspaceMoves),
AutomaticallyReadTitleChanges: boolPointer(settings.Reading.AutomaticallyReadTitleChanges),
PhoneticCharacterHints: boolPointer(settings.Reading.PhoneticCharacterHints),
EnablePauseBreaks: boolPointer(settings.Reading.EnablePauseBreaks),
@@ -300,6 +306,24 @@ func apply_profile_reading(settings *Settings, profile writableProfile) {
if value := profile.Presentation.Reading.AutomaticallyReadClipboardChanges; value != nil {
settings.Reading.AutomaticallyReadClipboardChanges = *value
}
if value := profile.Presentation.Reading.I3Modes; value != nil {
settings.Reading.I3Modes = *value
}
if value := profile.Presentation.Reading.I3Workspaces; value != nil {
settings.Reading.I3Workspaces = *value
}
if value := profile.Presentation.Reading.I3WindowStates; value != nil {
settings.Reading.I3WindowStates = *value
}
if value := profile.Presentation.Reading.I3Urgency; value != nil {
settings.Reading.I3Urgency = *value
}
if value := profile.Presentation.Reading.I3Marks; value != nil {
settings.Reading.I3Marks = *value
}
if value := profile.Presentation.Reading.I3WorkspaceMoves; value != nil {
settings.Reading.I3WorkspaceMoves = *value
}
if value := profile.Presentation.Reading.AutomaticallyReadTitleChanges; value != nil {
settings.Reading.AutomaticallyReadTitleChanges = *value
}
+6
View File
@@ -91,6 +91,12 @@ type writableTextAttributes struct {
type writableReading struct {
AutomaticallyReadClipboardChanges *bool `toml:"automatically-read-clipboard-changes,omitempty"`
I3Modes *bool `toml:"i3-modes,omitempty"`
I3Workspaces *bool `toml:"i3-workspaces,omitempty"`
I3WindowStates *bool `toml:"i3-window-states,omitempty"`
I3Urgency *bool `toml:"i3-urgency,omitempty"`
I3Marks *bool `toml:"i3-marks,omitempty"`
I3WorkspaceMoves *bool `toml:"i3-workspace-moves,omitempty"`
AutomaticallyReadTitleChanges *bool `toml:"automatically-read-title-changes,omitempty"`
PhoneticCharacterHints *bool `toml:"phonetic-character-hints,omitempty"`
EnablePauseBreaks *bool `toml:"enable-pause-breaks,omitempty"`
+6
View File
@@ -37,6 +37,12 @@ type Voice struct {
type ReadingSettings struct {
AutomaticallyReadClipboardChanges bool
I3Modes bool
I3Workspaces bool
I3WindowStates bool
I3Urgency bool
I3Marks bool
I3WorkspaceMoves bool
AutomaticallyReadTitleChanges bool
PhoneticCharacterHints bool
EnablePauseBreaks bool
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"time"
)
const queryTimeout = 2 * time.Second
type connection struct {
net.Conn
pending []message
pendingBytes int
}
// request preserves events interleaved with a reply on the subscribed socket.
// One worker owns both reads and comparisons; overflow forces a fresh baseline.
func (c *connection) request(kind uint32, body []byte, result any) error {
if err := c.SetDeadline(time.Now().Add(queryTimeout)); err != nil {
return err
}
defer c.SetDeadline(time.Time{})
if err := writeMessage(c, kind, body); err != nil {
return err
}
for {
m, err := readMessage(c)
if err != nil {
return err
}
if m.kind == kind {
return json.Unmarshal(m.body, result)
}
if m.kind&eventBit == 0 {
return errors.New("unexpected i3 reply")
}
if len(c.pending) >= maxPending || c.pendingBytes+len(m.body) > maxPayload {
return errors.New("i3 event backlog exceeded")
}
c.pending = append(c.pending, m)
c.pendingBytes += len(m.body)
}
}
func (c *connection) queryTree() (node, error) {
var root node
err := c.request(tree, nil, &root)
if err == nil && (root.Type != "root" || root.ID == 0) {
err = errors.New("invalid i3 tree")
}
return root, err
}
func session(ctx context.Context, path string, emit func(Event)) error {
socket, err := (&net.Dialer{Timeout: queryTimeout}).DialContext(ctx, "unix", path)
if err != nil {
return err
}
c := &connection{Conn: socket}
defer c.Close()
// Closing the descriptor interrupts both idle reads and queries immediately.
stop := context.AfterFunc(ctx, func() { c.Close() })
defer stop()
var subscribed struct {
Success bool `json:"success"`
}
if err := c.request(subscribe, []byte(`["workspace","mode","window"]`), &subscribed); err != nil {
return err
}
if !subscribed.Success {
return errors.New("i3 subscription rejected")
}
var spaces []workspace
if err := c.request(workspaces, nil, &spaces); err != nil {
return err
}
root, err := c.queryTree()
if err != nil {
return err
}
var state state
state.baseline(root, spaces)
// Everything received through the baseline reply belongs to the snapshot.
// This also suppresses reconnect snapshots and races during initialization.
c.pending = nil
c.pendingBytes = 0
for ctx.Err() == nil {
var m message
if len(c.pending) != 0 {
m = c.pending[0]
c.pendingBytes -= len(m.body)
c.pending[0] = message{}
c.pending = c.pending[1:]
} else {
// Idle sessions have no timeout; a partial frame must finish promptly.
// Read one byte first so an idle desktop doesn't reconnect periodically.
var first [1]byte
if _, err := c.Read(first[:]); err != nil {
return err
}
if err := c.SetReadDeadline(time.Now().Add(queryTimeout)); err != nil {
return err
}
m, err = readMessage(io.MultiReader(bytes.NewReader(first[:]), c))
c.SetReadDeadline(time.Time{})
if err != nil {
return err
}
}
events, err := state.apply(m, c.queryTree)
if err != nil {
return err
}
for _, event := range events {
if ctx.Err() != nil {
return ctx.Err()
}
emit(event)
}
}
return ctx.Err()
}
// Run rediscovers the active session after loss. All connection/cache state is
// local to one session and is discarded on errors. emit must be nonblocking.
// discover must verify the current window manager, not just an environment hint.
func Run(ctx context.Context, discover func() string, emit func(Event)) {
backoff := 250 * time.Millisecond
for ctx.Err() == nil {
started := time.Now()
if path := discover(); path != "" {
_ = session(ctx, path, emit)
}
if time.Since(started) > 10*time.Second {
backoff = 250 * time.Millisecond
}
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
backoff = min(5*time.Second, backoff*2)
}
}
+178
View File
@@ -0,0 +1,178 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"context"
"net"
"path/filepath"
"testing"
"time"
)
func listener(t *testing.T) net.Listener {
t.Helper()
l, err := net.Listen("unix", filepath.Join(t.TempDir(), "ipc"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
return l
}
func handshake(c net.Conn, initial string) error {
for _, response := range []string{`{"success":true}`, `[{"id":2,"name":"A","focused":true}]`, `{"id":1,"type":"root","nodes":[]}`} {
m, err := readMessage(c)
if err != nil {
return err
}
if initial != "" {
if err := writeMessage(c, modeEvent, []byte(initial)); err != nil {
return err
}
}
if err := writeMessage(c, m.kind, []byte(response)); err != nil {
return err
}
}
return nil
}
func TestSessionBaselineCancellationAndReconnect(t *testing.T) {
l := listener(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan Event, 8)
done := make(chan struct{})
server := make(chan error, 1)
go func() {
defer close(done)
Run(ctx, func() string { return l.Addr().String() }, func(e Event) { events <- e })
}()
go func() {
for _, name := range []string{"personal", "default"} {
c, err := l.Accept()
if err != nil {
server <- err
return
}
if err = handshake(c, `{"change":"snapshot"}`); err == nil {
err = writeMessage(c, modeEvent, []byte(`{"change":"`+name+`"}`))
}
if name == "default" && err == nil {
var b [1]byte
_, _ = c.Read(b[:])
}
c.Close()
if err != nil {
server <- err
return
}
}
server <- nil
}()
for _, name := range []string{"personal", "default"} {
select {
case e := <-events:
if e.Name != name {
t.Fatal(e)
}
case <-time.After(4 * time.Second):
t.Fatal("missing event/reconnect")
}
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("idle read did not cancel")
}
if err := <-server; err != nil {
t.Fatal(err)
}
select {
case e := <-events:
t.Fatalf("snapshot announced: %+v", e)
default:
}
}
func TestSubscriptionFailure(t *testing.T) {
l := listener(t)
go func() {
c, err := l.Accept()
if err != nil {
return
}
defer c.Close()
m, err := readMessage(c)
if err == nil {
_ = writeMessage(c, m.kind, []byte(`{"success":false}`))
}
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := session(ctx, l.Addr().String(), func(Event) { t.Error("unexpected event") }); err == nil {
t.Fatal("accepted rejection")
}
}
func TestUnavailableAndCanceledQuery(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := session(ctx, "/nonexistent/i3", func(Event) {}); err == nil {
t.Fatal("unavailable socket accepted")
}
l := listener(t)
accepted := make(chan struct{})
go func() {
c, err := l.Accept()
if err != nil {
return
}
defer c.Close()
close(accepted)
var b [4096]byte
for {
if _, err := c.Read(b[:]); err != nil {
return
}
}
}()
ctx, cancel = context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- session(ctx, l.Addr().String(), func(Event) {}) }()
<-accepted
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("query did not cancel")
}
}
func TestInterleavedEventsAndBacklog(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
c := connection{Conn: a}
go func() {
m, err := readMessage(b)
if err != nil {
return
}
_ = writeMessage(b, modeEvent, []byte(`{"change":"panel"}`))
_ = writeMessage(b, m.kind, []byte(`{"success":true}`))
}()
var result struct{ Success bool }
if err := c.request(subscribe, nil, &result); err != nil || !result.Success || len(c.pending) != 1 {
t.Fatalf("%+v %v", c.pending, err)
}
c.pending = make([]message, maxPending)
go func() {
_, err := readMessage(b)
if err == nil {
_ = writeMessage(b, modeEvent, []byte(`{}`))
}
}()
if err := c.request(tree, nil, &result); err == nil {
t.Fatal("unbounded pending events")
}
}
+223
View File
@@ -0,0 +1,223 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
func TestNativeI3Events(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_I3") != "1" {
t.Skip("requires private i3 harness")
}
path := xorg.I3Socket()
if path == "" {
t.Fatal("active i3/socket not detected")
}
// A stale environment hint must not override the verified session property.
t.Setenv("I3SOCK", "/nonexistent/stale-socket")
if xorg.I3Socket() != path {
t.Fatal("stale environment overrode discovery")
}
query := func(kind uint32, payload string, result any) {
t.Helper()
c, err := net.DialTimeout("unix", path, time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(2 * time.Second))
if err = writeMessage(c, kind, []byte(payload)); err != nil {
t.Fatal(err)
}
m, err := readMessage(c)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(m.body, result); err != nil {
t.Fatal(err)
}
}
command := func(text string) {
t.Helper()
var replies []struct{ Success bool }
query(0, text, &replies)
for _, r := range replies {
if !r.Success {
t.Fatalf("i3 rejected %q", text)
}
}
}
getTree := func() node { var root node; query(tree, "", &root); return root }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan Event, 128)
done := make(chan error, 1)
go func() { done <- session(ctx, path, func(e Event) { events <- e }) }()
// Synchronize baseline completion through an actual reported mode transition.
timer := time.NewTicker(30 * time.Millisecond)
defer timer.Stop()
ready := false
deadline := time.After(4 * time.Second)
for !ready {
select {
case <-timer.C:
command(`mode "personal"`)
case e := <-events:
ready = e.Kind == Mode && e.Name == "personal"
case err := <-done:
t.Fatal(err)
case <-deadline:
t.Fatal("backend did not become ready")
}
}
expect := func(kind Kind, name string) Event {
t.Helper()
for {
select {
case e := <-events:
if e.Kind == kind && (name == "" || e.Name == name) {
return e
}
t.Logf("additional event: %+v", e)
case err := <-done:
t.Fatalf("backend exited: %v", err)
case <-time.After(3 * time.Second):
t.Fatalf("missing event %d %q", kind, name)
}
}
}
quiet := func() {
t.Helper()
select {
case e := <-events:
t.Fatalf("unexpected announcement: %+v", e)
case <-time.After(100 * time.Millisecond):
}
}
for _, mode := range []string{"ratpoison", "panel", "default"} {
command(`mode "` + mode + `"`)
expect(Mode, mode)
}
command(`mode "<b>marked &amp; mode</b>"`)
if e := expect(Mode, ""); !e.Markup || e.Name != "<b>marked &amp; mode</b>" {
t.Fatal(e)
}
command(`mode "default"`)
expect(Mode, "default")
command(`workspace "empty arbitrary & name"`)
expect(Workspace, "empty arbitrary & name")
command(`rename workspace to "renamed empty"`)
expect(Workspace, "renamed empty")
terminal := exec.Command("xterm", "-T", "i3 fixture", "-e", "sleep", "60")
if err := terminal.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = terminal.Process.Kill(); _ = terminal.Wait() }()
var window node
var find func(node)
find = func(n node) {
if n.Name == "i3 fixture" && n.Window != 0 {
window = n
}
for _, list := range [][]node{n.Nodes, n.FloatingNodes} {
for _, child := range list {
find(child)
}
}
}
until := time.Now().Add(4 * time.Second)
for window.ID == 0 && time.Now().Before(until) {
find(getTree())
if window.ID == 0 {
time.Sleep(20 * time.Millisecond)
}
}
if window.ID == 0 {
t.Fatal("fixture window missing")
}
// A marker event follows window creation/focus, proving those are silent.
command(`mode "panel"`)
expect(Mode, "panel")
quiet()
target := fmt.Sprintf("[con_id=%d] ", window.ID)
for _, tc := range []struct {
command string
kind Kind
enabled bool
}{
{"fullscreen enable", Fullscreen, true}, {"fullscreen disable", Fullscreen, false},
{"fullscreen enable global", Fullscreen, true}, {"fullscreen disable", Fullscreen, false},
{"floating enable", Floating, true}, {"floating disable", Floating, false},
} {
command(target + tc.command)
e := expect(tc.kind, "")
if e.Enabled != tc.enabled {
t.Fatal(e)
}
}
command(target + `mark "a & mark"`)
expect(MarkAdded, "a & mark")
command(target + `unmark "a & mark"`)
expect(MarkRemoved, "a & mark")
command(target + `floating enable`)
expect(Floating, "")
command(target + `move position center`)
quiet()
command(target + `floating disable`)
expect(Floating, "")
command(target + `move container to workspace "destination"`)
if e := expect(WorkspaceMove, ""); e.Workspace != "destination" {
t.Fatal(e)
}
// It is now a background window; urgency must identify both contexts once.
out, err := exec.Command("xdotool", "set_window", "--urgency", "1", strconv.FormatUint(window.Window, 10)).CombinedOutput()
if err != nil {
t.Fatalf("urgency: %v %s", err, out)
}
if e := expect(Urgent, ""); e.Window != "i3 fixture" || e.Workspace != "destination" {
t.Fatal(e)
}
quiet()
command(target + `fullscreen enable`)
if e := expect(Fullscreen, ""); e.Focused || e.Window != "i3 fixture" {
t.Fatal(e)
}
command(target + `fullscreen disable`)
expect(Fullscreen, "")
command(target + `move scratchpad`)
quiet()
command(target + `scratchpad show`)
quiet()
cancel()
select {
case err := <-done:
if err != nil && !strings.Contains(err.Error(), "closed") && ctx.Err() == nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("backend did not stop")
}
}
func TestNativeI3Unavailable(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_I3_UNAVAILABLE") != "1" {
t.Skip("requires empty private X display")
}
t.Setenv("I3SOCK", "/tmp/stale-i3")
if path := xorg.I3Socket(); path != "" {
t.Fatalf("activated without i3: %q", path)
}
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
// Package i3ipc observes i3 without issuing window-management commands.
package i3ipc
import (
"encoding/binary"
"encoding/json"
"errors"
"io"
)
const (
workspaces = 1
subscribe = 2
tree = 4
eventBit uint32 = 1 << 31
workspaceEvent = eventBit
modeEvent = eventBit | 2
windowEvent = eventBit | 3
// A corrupt peer cannot cause an unbounded allocation or event backlog.
maxPayload = 16 << 20
maxPending = 256
)
type message struct {
kind uint32
body []byte
}
func readMessage(r io.Reader) (message, error) {
var header [14]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return message{}, err
}
if string(header[:6]) != "i3-ipc" {
return message{}, errors.New("invalid i3 magic")
}
size := binary.NativeEndian.Uint32(header[6:10])
if size > maxPayload {
return message{}, errors.New("i3 payload too large")
}
body := make([]byte, int(size))
if _, err := io.ReadFull(r, body); err != nil {
return message{}, err
}
if len(body) != 0 && !json.Valid(body) {
return message{}, errors.New("invalid i3 JSON")
}
return message{binary.NativeEndian.Uint32(header[10:]), body}, nil
}
func writeMessage(w io.Writer, kind uint32, body []byte) error {
if len(body) > maxPayload {
return errors.New("i3 payload too large")
}
var header [14]byte
copy(header[:], "i3-ipc")
binary.NativeEndian.PutUint32(header[6:10], uint32(len(body)))
binary.NativeEndian.PutUint32(header[10:], kind)
for _, part := range [][]byte{header[:], body} {
for len(part) > 0 {
n, err := w.Write(part)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
part = part[n:]
}
}
return nil
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"bytes"
"encoding/binary"
"io"
"testing"
)
type fragments struct{ io.Reader }
func (r fragments) Read(b []byte) (int, error) { return r.Reader.Read(b[:min(1, len(b))]) }
func TestFraming(t *testing.T) {
var wire bytes.Buffer
body := []byte(`{"change":"résumé"}`)
if err := writeMessage(&wire, modeEvent, body); err != nil {
t.Fatal(err)
}
frame := bytes.Clone(wire.Bytes())
got, err := readMessage(fragments{&wire})
if err != nil || got.kind != modeEvent || !bytes.Equal(got.body, body) {
t.Fatalf("%+v %v", got, err)
}
for name, bad := range map[string][]byte{
"header": frame[:12], "body": frame[:len(frame)-1],
"magic": append([]byte("no-ipc"), frame[6:]...),
"json": append(bytes.Clone(frame[:14]), bytes.Repeat([]byte("x"), len(body))...),
} {
t.Run(name, func(t *testing.T) {
if _, err := readMessage(bytes.NewReader(bad)); err == nil {
t.Fatal("accepted malformed frame")
}
})
}
huge := bytes.Clone(frame[:14])
binary.NativeEndian.PutUint32(huge[6:10], maxPayload+1)
if _, err := readMessage(bytes.NewReader(huge)); err == nil {
t.Fatal("accepted oversized frame")
}
}
+241
View File
@@ -0,0 +1,241 @@
// 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
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"encoding/json"
"reflect"
"testing"
)
func ptr[T any](v T) *T { return &v }
func testWindow() node {
return node{ID: 10, Window: 100, Name: "Editor", Focused: true, Marks: []string{}, Urgent: ptr(false), Fullscreen: ptr(0), Floating: "auto_off"}
}
func testRoot(n node, ws uint64, name string) node {
return node{ID: 1, Type: "root", Nodes: []node{{ID: ws, Type: "workspace", Name: name, Nodes: []node{n}}}}
}
func applyTest(t *testing.T, s *state, kind uint32, change string, n node, root node) []Event {
t.Helper()
data := map[string]any{"change": change, "container": n, "current": n}
raw, _ := json.Marshal(data)
got, err := s.apply(message{kind, raw}, func() (node, error) { return root, nil })
if err != nil {
t.Fatal(err)
}
return got
}
func TestConfirmedStateChanges(t *testing.T) {
n := testWindow()
var s state
root := testRoot(n, 2, "A")
s.baseline(root, []workspace{{ID: 2, Name: "A", Focused: true}})
if got := applyTest(t, &s, windowEvent, "focus", n, root); len(got) != 0 {
t.Fatal(got)
}
n.Fullscreen = ptr(1)
if got := applyTest(t, &s, windowEvent, "fullscreen_mode", n, root); len(got) != 1 || got[0].Kind != Fullscreen || !got[0].Enabled {
t.Fatal(got)
}
n.Fullscreen = ptr(0)
n.Focused = false
if got := applyTest(t, &s, windowEvent, "fullscreen_mode", n, root); len(got) != 1 || got[0].Enabled || got[0].Window != "Editor" || got[0].Focused {
t.Fatal(got)
}
n.Floating = "user_on"
if got := applyTest(t, &s, windowEvent, "floating", n, root); len(got) != 1 || got[0].Kind != Floating || !got[0].Enabled {
t.Fatal(got)
}
n.Floating = "user_off"
if got := applyTest(t, &s, windowEvent, "floating", n, root); len(got) != 1 || got[0].Enabled {
t.Fatal(got)
}
n.Urgent = ptr(true)
root = testRoot(n, 2, "A")
if got := applyTest(t, &s, windowEvent, "urgent", n, root); len(got) != 1 || got[0].Kind != Urgent || got[0].Workspace != "A" {
t.Fatal(got)
}
if got := applyTest(t, &s, windowEvent, "urgent", n, root); len(got) != 0 {
t.Fatal(got)
}
if got := applyTest(t, &s, workspaceEvent, "urgent", root.Nodes[0], root); len(got) != 0 {
t.Fatal(got)
}
n.Urgent = ptr(false)
if got := applyTest(t, &s, windowEvent, "urgent", n, root); len(got) != 0 {
t.Fatal(got)
}
n.Marks = []string{"z", "a"}
got := applyTest(t, &s, windowEvent, "mark", n, root)
if len(got) != 2 || got[0].Name != "a" || got[1].Name != "z" || got[0].Kind != MarkAdded {
t.Fatal(got)
}
n.Marks = []string{"a", "z"}
if got := applyTest(t, &s, windowEvent, "mark", n, root); len(got) != 0 {
t.Fatal(got)
}
n.Marks = []string{}
if got := applyTest(t, &s, windowEvent, "mark", n, root); len(got) != 2 || got[0].Kind != MarkRemoved {
t.Fatal(got)
}
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 0 {
t.Fatal(got)
}
root = testRoot(n, 3, "résumé & 99")
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 1 || got[0].Kind != WorkspaceMove || got[0].Workspace != "résumé & 99" {
t.Fatal(got)
}
root = testRoot(n, 4, "__i3_scratch")
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 0 {
t.Fatal(got)
}
root = testRoot(n, 3, "B")
if got := applyTest(t, &s, windowEvent, "move", n, root); len(got) != 0 {
t.Fatal(got)
}
}
func TestWorkspacesAndModes(t *testing.T) {
n := testWindow()
var s state
s.baseline(testRoot(n, 2, "A"), []workspace{{ID: 2, Focused: true}})
for _, change := range []string{"focus", "rename"} {
ws := node{ID: 2, Name: "empty & arbitrary", Type: "workspace"}
if got := applyTest(t, &s, workspaceEvent, change, ws, node{}); len(got) != 1 || got[0].Kind != Workspace || got[0].Name != ws.Name {
t.Fatal(got)
}
}
if got := applyTest(t, &s, workspaceEvent, "rename", node{ID: 3, Name: "background"}, node{}); len(got) != 0 {
t.Fatal(got)
}
raw := []byte(`{"change":"<b>personal &amp; more</b>","pango_markup":true}`)
got, err := s.apply(message{modeEvent, raw}, nil)
want := []Event{{Kind: Mode, Name: "<b>personal &amp; more</b>", Markup: true}}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("%+v %v", got, err)
}
}
func TestUnknownContextAndMissingEvidence(t *testing.T) {
n := testWindow()
var s state
s.baseline(node{}, nil)
if got := applyTest(t, &s, windowEvent, "mark", n, node{}); len(got) != 0 {
t.Fatal(got)
}
if got := applyTest(t, &s, windowEvent, "move", n, testRoot(n, 3, "B")); len(got) != 0 {
t.Fatal(got)
}
n.Fullscreen = nil
n.Floating = "unknown"
for _, change := range []string{"fullscreen_mode", "floating", "new", "close", "title"} {
if got := applyTest(t, &s, windowEvent, change, n, node{}); len(got) != 0 {
t.Fatal(got)
}
}
n = testWindow()
n.Urgent = ptr(true)
if got := applyTest(t, &s, windowEvent, "urgent", n, node{Type: "root"}); len(got) != 1 || got[0].Workspace != "" {
t.Fatal(got)
}
}
func TestScratchpadImplicitFloatingSuppressed(t *testing.T) {
n := testWindow()
var s state
s.baseline(testRoot(n, 2, "A"), nil)
n.Floating = "user_on" // i3 reports this before assigning the scratchpad.
if got := applyTest(t, &s, windowEvent, "floating", n, testRoot(n, 4, "__i3_scratch")); len(got) != 0 {
t.Fatal(got)
}
if got := applyTest(t, &s, windowEvent, "move", n, testRoot(n, 4, "__i3_scratch")); len(got) != 0 {
t.Fatal(got)
}
if got := applyTest(t, &s, windowEvent, "move", n, testRoot(n, 2, "A")); len(got) != 0 {
t.Fatal(got)
}
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package xorg
/*
#include "commands_native.h"
#include <X11/Xlib.h>
#include <X11/Xatom.h>
#include <stdlib.h>
#include <string.h>
static unsigned char *i3_property(Display *d, Window w, const char *name, Atom wanted, int bits) {
Atom type; int format; unsigned long count, remaining;
unsigned char *value = NULL;
if (XGetWindowProperty(d, w, XInternAtom(d, name, False), 0, 1024, False,
wanted, &type, &format, &count, &remaining, &value) != Success ||
type != wanted || format != bits || remaining || !count) {
if (value) XFree(value);
return NULL;
}
return value;
}
static char *i3_socket(void) {
Display *d = XOpenDisplay(NULL);
if (!d) return NULL;
cthulhu_x11_begin_read(d);
char *socket = NULL;
Window root = DefaultRootWindow(d);
unsigned char *check = i3_property(d, root, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32);
if (check) {
Window wm = *(unsigned long *)check;
XFree(check);
unsigned char *self = i3_property(d, wm, "_NET_SUPPORTING_WM_CHECK", XA_WINDOW, 32);
unsigned char *name = i3_property(d, wm, "_NET_WM_NAME", XInternAtom(d, "UTF8_STRING", False), 8);
if (self && *(unsigned long *)self == wm && name && !strcmp((char *)name, "i3")) {
unsigned char *path = i3_property(d, root, "I3_SOCKET_PATH", XInternAtom(d, "UTF8_STRING", False), 8);
if (path) { socket = strdup((char *)path); XFree(path); }
}
if (self) XFree(self);
if (name) XFree(name);
}
if (cthulhu_x11_end_read(d)) { free(socket); socket = NULL; }
XCloseDisplay(d);
return socket;
}
*/
import "C"
import (
"os"
"unsafe"
)
// I3Socket verifies the active X11 WM's self-referencing EWMH identity and
// reads its root socket property. An installed executable or stale I3SOCK
// alone cannot enable this backend. Called only by the desktop worker.
func I3Socket() string {
if os.Getenv("DISPLAY") == "" {
return ""
}
path := C.i3_socket()
if path == nil {
return ""
}
defer C.free(unsafe.Pointer(path))
return C.GoString(path)
}
+12
View File
@@ -7,6 +7,18 @@ static GtkListStore *reading_store;
static const struct { const char *id; const char *label; const char *description; } reading_rows[] = {
{"only_speak_displayed_text", "Only speak displayed text",
"Limits reading to displayed names, text and values. Omits extra information such as control types, states and container announcements."},
{"i3_modes", "Announce i3 binding modes",
"Announces the configured i3 binding mode when it changes."},
{"i3_workspaces", "Announce i3 workspaces",
"Announces workspace focus and focused workspace renames in i3."},
{"i3_window_states", "Announce i3 window states",
"Announces fullscreen and floating changes in i3."},
{"i3_urgency", "Announce i3 urgent windows",
"Announces windows becoming urgent in i3."},
{"i3_marks", "Announce i3 marks",
"Announces confirmed additions and removals of i3 window marks."},
{"i3_workspace_moves", "Announce i3 window workspace moves",
"Announces confirmed moves of windows between i3 workspaces."},
{"automatically_read_title_changes", "Automatically read title changes",
"Announces changes to the active window's title, such as a new page title in a browser."},
{"automatically_read_clipboard_changes", "Automatically read clipboard changes",
+6
View File
@@ -39,6 +39,12 @@ func readingChecks(values *Values) []struct {
value *bool
}{
{"only_speak_displayed_text", &values.Reading.OnlySpeakDisplayedText},
{"i3_modes", &values.Reading.I3Modes},
{"i3_workspaces", &values.Reading.I3Workspaces},
{"i3_window_states", &values.Reading.I3WindowStates},
{"i3_urgency", &values.Reading.I3Urgency},
{"i3_marks", &values.Reading.I3Marks},
{"i3_workspace_moves", &values.Reading.I3WorkspaceMoves},
{"automatically_read_title_changes", &values.Reading.AutomaticallyReadTitleChanges},
{"automatically_read_clipboard_changes", &values.Reading.AutomaticallyReadClipboardChanges},
{"phonetic_character_hints", &values.Reading.PhoneticCharacterHints},
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Exercise actual i3 IPC on a private Xvfb display; never touch the live WM."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
from native_test_session import run_private_session
from native_x11 import wait_for
ROOT = Path(__file__).resolve().parents[1]
def inside():
env = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off")
subprocess.run(["go", "test", "./internal/i3ipc", "-run", "^TestNativeI3Unavailable$", "-count=1", "-v"],
cwd=ROOT, env=dict(env, CTHULHU_NATIVE_I3_UNAVAILABLE="1"), check=True, timeout=60)
with tempfile.TemporaryDirectory(prefix="cthulhu-i3-") as directory:
config = Path(directory) / "config"
config.write_text('font pango:monospace 8\nfocus_follows_mouse no\n'
'mode "personal" {\n bindsym Escape mode "default"\n}\n'
'mode "ratpoison" {\n bindsym Escape mode "default"\n}\n'
'mode "panel" {\n bindsym Escape mode "default"\n}\n'
'mode --pango_markup "<b>marked &amp; mode</b>" {\n bindsym Escape mode "default"\n}\n')
with (Path(directory) / "i3.log").open("w+") as log:
wm = subprocess.Popen(["i3", "-c", str(config)], env=env, stdout=log, stderr=log)
try:
def ready():
if wm.poll() is not None:
log.seek(0)
raise RuntimeError(log.read())
result = subprocess.run(["i3", "--get-socketpath"], env=env, capture_output=True, text=True)
return result.returncode == 0 and Path(result.stdout.strip()).is_socket()
wait_for(ready, "private i3 did not start")
subprocess.run(["go", "test", "./internal/i3ipc", "-run", "^TestNativeI3Events$", "-count=1", "-v", "-timeout=30s"],
cwd=ROOT, env=dict(env, CTHULHU_NATIVE_I3="1"), check=True, timeout=60)
finally:
wm.terminate()
wm.wait(timeout=5)
def main():
if "--inside" in sys.argv:
inside()
return
read_fd, write_fd = os.pipe()
server = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", "-nolisten", "tcp"],
pass_fds=(write_fd,), stderr=subprocess.DEVNULL)
os.close(write_fd)
try:
with os.fdopen(read_fd) as reader:
display = reader.readline().strip()
assert display, "Xvfb failed"
env = dict(os.environ, DISPLAY=":" + display)
for key in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY", "I3SOCK"):
env.pop(key, None)
run_private_session(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"],
env=env, check=True, timeout=150)
finally:
server.terminate()
server.wait(timeout=5)
if __name__ == "__main__":
main()
+24 -1
View File
@@ -95,7 +95,9 @@ def inside():
responses = ResponseReader(process.stdout)
initial = {"Values": {"Rate": 50, "Pitch": 5, "Volume": 5,
"SpeechHistoryLimit": 50,
"Reading": {"AutomaticallyReadTitleChanges": True},
"Reading": {"AutomaticallyReadTitleChanges": True,
"I3Modes": True, "I3Workspaces": True, "I3WindowStates": True,
"I3Urgency": True, "I3Marks": True, "I3WorkspaceMoves": True},
"KeyboardLayout": 1, "CthulhuModifierKeys": ["Insert", "KP_Insert"],
"Punctuation": 1, "RaisePitchForCapitals": True,
"BeepForCapitals": False, "SayCapForCapitals": False,
@@ -1067,6 +1069,23 @@ def inside():
assert descriptionCell.name and descriptionCell.name != settingCell.name, settingCell.name
checked = stateCell.getState().contains(pyatspi.STATE_CHECKED)
assert stateCell.name == ("checked" if checked else "unchecked"), stateCell.name
i3Choices = {
"I3Modes": "Announce i3 binding modes",
"I3Workspaces": "Announce i3 workspaces",
"I3WindowStates": "Announce i3 window states",
"I3Urgency": "Announce i3 urgent windows",
"I3Marks": "Announce i3 marks",
"I3WorkspaceMoves": "Announce i3 window workspace moves",
}
i3Rows = {}
for field, label in i3Choices.items():
row = next(row for row in range(readingTable.nRows)
if readingTable.getAccessibleAt(row, 1).name == label)
i3Rows[field] = row
cell = readingTable.getAccessibleAt(row, 0)
assert cell.name == "checked", (label, cell.name)
focus(cell); key("space")
wait_for(lambda cell=cell: cell.name == "unchecked", label + " did not toggle")
titleRow = next(row for row in range(readingTable.nRows)
if readingTable.getAccessibleAt(row, 1).name == "Automatically read title changes")
titleCell = readingTable.getAccessibleAt(titleRow, 0)
@@ -1133,6 +1152,8 @@ def inside():
focus(named("Apply")); key("space")
applied = request()
assert applied["Values"]["Reading"]["OnlySpeakDisplayedText"] != wasChecked, applied
for field in i3Choices:
assert not applied["Values"]["Reading"][field], applied
assert not applied["Values"]["Reading"]["AutomaticallyReadTitleChanges"], applied
assert applied["Values"]["Reading"]["AutomaticallyReadClipboardChanges"], applied
assert applied["Values"]["Reading"]["PhoneticCharacterHints"], applied
@@ -1143,6 +1164,8 @@ def inside():
wait_for(lambda: named("Apply").getState().contains(pyatspi.STATE_SENSITIVE), "Apply busy")
assert readingTable.getAccessibleAt(0, 0).name == ("unchecked" if wasChecked else "checked")
assert readingTable.getAccessibleAt(titleRow, 0).name == "unchecked"
for row in i3Rows.values():
assert readingTable.getAccessibleAt(row, 0).name == "unchecked"
assert readingTable.getAccessibleAt(clipboardRow, 0).name == "checked"
assert readingTable.getAccessibleAt(hintRow, 0).name == "checked"
assert readingTable.getAccessibleAt(next(row for row in range(readingTable.nRows)