Add native i3 desktop announcements
This commit is contained in:
1 parent
49eeae89f4
commit
bf1f155018
22 files changed
+1835
-1
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}}
|
||||
}
|
||||
@@ -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é & 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user