Some echo tests added.

This commit is contained in:
Storm Dragon committed 2026-10-27 00:40:49 -04:00
1 parent 8a32895457
commit eb5b2038d6
38 files changed
+1739 -31

No files matched your search

+7
View File
@@ -11,6 +11,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/platform/session"
"os"
"os/exec"
"strings"
"time"
)
@@ -62,3 +63,9 @@ func readClipboardMessage(ctx context.Context) (string, error) {
}
return result.Message, nil
}
func writeClipboardText(ctx context.Context, value string) error {
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in")
command.Stdin = strings.NewReader(value)
return command.Run()
}
+40
View File
@@ -208,6 +208,46 @@ func test_echo_speaker(t *testing.T, wrapped bool) {
t.Fatalf("key echo custom=%v: %+v", custom, got)
}
}
// Drive the insertion presenter through the real SSIP clients for every
// echo kind. Per-kind voice selection must not affect other echo kinds.
for _, kind := range []string{"key", "character", "word", "sentence"} {
for _, selected := range []bool{false, true} {
current := settings
current.EnableEchoByCharacter = kind == "character"
current.EnableEchoByWord = kind == "word"
current.EnableEchoBySentence = kind == "sentence"
current.UseCustomEchoForKey = selected
current.UseCustomEchoForCharacter = selected
current.UseCustomEchoForWord = selected
current.UseCustomEchoForSentence = selected
live := &liveSettings{}
live.Store(current)
queue := &remoteSpeechQueue{}
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
payload, contextText, want := "é", "Café", "é"
if kind == "word" {
payload, contextText, want = " ", "Café ", "Café"
} else if kind == "sentence" {
payload, contextText, want = " ", "First. Second. ", "Second."
}
presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: payload,
Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: contextText}})
if len(queue.requests) != 1 {
t.Fatalf("%s: requests=%+v", kind, queue.requests)
}
if err := keySpeech.deliver(ctx, queue.requests[0]); err != nil {
t.Fatal(err)
}
got := <-spoken
wantModule, wantRate := "", ""
if selected {
wantModule, wantRate = "echo-module", "62"
}
if got.text != want || got.module != wantModule || got.rate != wantRate {
t.Fatalf("%s custom=%v: %+v", kind, selected, got)
}
}
}
// Preview unsaved echo selections through the real SSIP clients. Only the
// numeric request uses that endpoint; subsequent dialog speech stays main.
previewWorker := newPreferencesWorker(slog.New(slog.DiscardHandler), settings, speaker)
+4
View File
@@ -40,6 +40,7 @@ type accessibilityEventHandler struct {
syncForeground func()
flushTerminalDeletion func()
speak func(atspi.Event, ...input.Intent)
messaging *messagingController
webScripts *webScriptController
wineReview *wineReviewFocusController
}
@@ -110,6 +111,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"selection_end", event.Source.SelectionEnd)
}
syncForeground()
if handler.messaging.handle(event) {
return
}
notification := eventBypassesXTermHandoff(event)
if handler.wineReview != nil && handler.wineReview.owns(state.current.ID()) && !notification {
return
+17 -2
View File
@@ -137,6 +137,11 @@ func processEventsWithAttributes(
Flash(string, config.BrailleOptions) bool
})
defer webScripts.reset()
messages := newMessagingController(ctx, speech, focusGate, live, writeClipboardText)
messages.braille, _ = brailleOutput.(interface {
Flash(string, config.BrailleOptions) bool
})
defer messages.stop()
logger.Info("listening for focus events")
browser, _ := inputSource.(*browserModeController)
var modeResults <-chan browserModeResult
@@ -182,6 +187,7 @@ func processEventsWithAttributes(
speak(event)
}
clearObjectFocus := func() {
messages.cancelCopyRequest()
ocrSession.Retire()
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
}
@@ -225,7 +231,7 @@ func processEventsWithAttributes(
brailleOutput: brailleOutput, foreground: foreground, debounce: debounce,
clearOwnedFocus: clearOwnedFocus, syncForeground: syncForeground,
flushTerminalDeletion: flushTerminalDeletion, speak: speak, webScripts: webScripts,
wineReview: wineReview,
wineReview: wineReview, messaging: messages,
}
var lockChanges <-chan struct{}
@@ -241,10 +247,14 @@ func processEventsWithAttributes(
wineReview.retire()
}
webScripts.reset()
messages.reset()
state.owner.clear()
clearOwnedFocus()
case <-wineReviewChanges:
syncForeground()
case result := <-messages.results:
syncForeground()
messages.copied(result)
case <-webScripts.due:
webScripts.flush()
case <-foregroundChanges:
@@ -335,7 +345,12 @@ func processEventsWithAttributes(
case input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn:
reviewHandler.configureCalcHeader(command)
default:
reviewHandler.whereAmI()
if _, _, ok := input.MessageCommandIndex(command); ok {
syncForeground()
messages.review(command, state.current)
} else {
reviewHandler.whereAmI()
}
}
case <-attributeRequests:
if !focusGate.Locked() {
+1 -1
View File
@@ -125,7 +125,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
if speaksTextChanges(event.Source) && event.Source.Path != "" && !event.Source.LiveRegion {
state.typingPath = event.Source.Path
state.typingText = event.Source.TextBeforeCaret
if state.typingText == "" && event.Payload != "\n" && event.Payload != "\t" {
if state.typingText == "" && !event.Source.TextCaretKnown && event.Payload != "\n" && event.Payload != "\t" {
state.typingText = event.Source.Text
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/version"
)
const learnModeInstructions = "Entering learn mode. Press any key to hear its function. Press F1 for Cthulhu documentation, F2 for default shortcuts, or F3 for application shortcuts. Press Escape, Cthulhu H, or Cthulhu F1 to exit."
const learnModeInstructions = "Entering learn mode. Press any key to hear its function. Press F1 for Cthulhu documentation, F2 for default shortcuts, or F3 for application shortcuts. Press Escape or Cthulhu H to exit."
type learnModePresenter struct {
tentacles *tentacleRuntime
@@ -46,7 +46,7 @@ func (presenter *learnModePresenter) handle(event input.CommandEvent) {
presenter.lastTime = 0
presenter.speech.Enqueue(speechRequest{Text: version.String(), Role: "system", Cause: "learn-mode"})
presenter.speech.Enqueue(speechRequest{Text: learnModeInstructions, Role: "system", Cause: "learn-mode"})
presenter.braille.EnqueueMessage("Learn mode. Press Escape, Cthulhu H, or Cthulhu F1 to exit.")
presenter.braille.EnqueueMessage("Learn mode. Press Escape or Cthulhu H to exit.")
return
}
presenter.key(event)
+2 -2
View File
@@ -34,7 +34,7 @@ func TestLearnModePresentsKeyCommandsWithoutExecutingThem(t *testing.T) {
braille := &learnBrailleRecorder{}
presenter := &learnModePresenter{ctx: context.Background(), logger: slog.New(slog.DiscardHandler), speech: speech, braille: braille, live: live}
presenter.handle(input.CommandEvent{Command: input.ToggleLearnMode, LearnModeStarted: true})
if !presenter.active || len(speech.texts) != 2 || !strings.Contains(speech.texts[1], "Cthulhu F1") || len(braille.texts) != 1 {
if !presenter.active || len(speech.texts) != 2 || !strings.Contains(speech.texts[1], "Escape or Cthulhu H") || strings.Contains(speech.texts[1], "Cthulhu F1") || len(braille.texts) != 1 {
t.Fatalf("start speech=%q braille=%q", speech.texts, braille.texts)
}
presenter.handle(input.CommandEvent{Command: input.LearnKey, Chord: input.CommandChord{Symbol: 'q', CthulhuModifier: true}, KeyName: "q", Timestamp: 100})
@@ -66,7 +66,7 @@ func TestLearnShortcutListUsesActiveBindings(t *testing.T) {
foundPreferences := false
for _, row := range list.Rows {
if row.Label == "Enter or exit learn mode." {
foundLearn = strings.Contains(row.Keys, "Cthulhu+h") && strings.Contains(row.Keys, "Cthulhu+F1")
foundLearn = row.Keys == "Cthulhu+h"
}
if row.Label == "Open Cthulhu preferences." {
foundPreferences = true
+177
View File
@@ -0,0 +1,177 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"fmt"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/messaging"
)
type messageCopyRequest struct {
ctx context.Context
serial uint64
text string
}
type messageCopyResult struct {
messageCopyRequest
err error
}
// messagingController belongs to the accessibility event loop. Histories survive
// focus changes, retire with their native window/transcript, and clear on lock.
// Its one clipboard worker has a bounded queue and never performs native queries.
type messagingController struct {
history messaging.History
speech speechEnqueuer
braille interface {
Flash(string, config.BrailleOptions) bool
}
live *liveSettings
gate *focusGate
ctx context.Context
cancelWorker context.CancelFunc
cancelCopy context.CancelFunc
serial uint64
requests chan messageCopyRequest
results chan messageCopyResult
done chan struct{}
}
func newMessagingController(ctx context.Context, speech speechEnqueuer, gate *focusGate, live *liveSettings, write func(context.Context, string) error) *messagingController {
ctx, cancelWorker := context.WithCancel(ctx)
controller := &messagingController{ctx: ctx, cancelWorker: cancelWorker, speech: speech, gate: gate, live: live,
requests: make(chan messageCopyRequest, 1), results: make(chan messageCopyResult, 1), done: make(chan struct{})}
go func() {
defer close(controller.done)
for {
select {
case <-ctx.Done():
return
case request := <-controller.requests:
if request.ctx.Err() != nil || gate.Locked() {
continue
}
err := write(request.ctx, request.text)
if errors.Is(request.ctx.Err(), context.Canceled) {
continue
}
select {
case controller.results <- messageCopyResult{request, err}:
case <-ctx.Done():
return
}
}
}
}()
return controller
}
func (controller *messagingController) handle(event atspi.Event) bool {
if controller == nil {
return false
}
if event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.DefunctStateEvent && event.Detail1 != 0 {
id := event.Source.ID()
if event.Type == atspi.WindowDestroyedEvent && event.Window.Valid() {
id = event.Window
}
controller.history.Retire(id.BusName, id.Path)
}
message := event.ChatMessage
if message == nil || event.Type != atspi.TextInsertedEvent || event.Source.ApplicationProcess != "pidgin" ||
event.Source.Defunct || event.Source.Role != "text" ||
message.Provider != event.Source.BusName || message.Conversation != event.Source.Path ||
!event.Source.ID().Valid() || event.Window != atspi.NewObjectID(message.Provider, message.Window) {
return false
}
if !controller.gate.Locked() && controller.history.Add(*message) {
stored, _ := controller.history.Previous(message.Provider, 0)
controller.present(stored.Text, true)
}
return true
}
func (controller *messagingController) present(text string, background bool) {
if controller.gate.Locked() {
return
}
if controller.braille != nil {
options := config.DefaultSettings().Braille
if controller.live != nil {
options = controller.live.Load().Braille
}
controller.braille.Flash(text, options)
}
// The controller owns the flash; avoid a duplicate system-message flash.
controller.speech.Enqueue(speechRequest{Text: text, Role: "notification", Cause: "chat-message", BypassFocusGate: background})
}
func (controller *messagingController) review(command input.Command, current atspi.Snapshot) {
index, copyMessage, ok := input.MessageCommandIndex(command)
if !ok || controller.gate.Locked() || controller.gate.Suspended() || current.ApplicationProcess != "pidgin" {
return
}
message, exists := controller.history.Previous(current.BusName, index)
if !exists {
return
} // Legacy leaves empty history slots silent.
if !copyMessage {
controller.present(message.Text, false)
return
}
controller.cancelCopyRequest()
copyCtx, cancel := context.WithTimeout(controller.ctx, clipboardReadTimeout)
controller.cancelCopy = cancel
request := messageCopyRequest{ctx: copyCtx, serial: controller.serial, text: message.Text}
// Only the event loop produces requests. Replace an obsolete queued copy
// rather than dropping the newest command while the worker cancels a write.
select {
case <-controller.requests:
default:
}
select {
case controller.requests <- request:
default:
cancel()
}
}
func (controller *messagingController) copied(result messageCopyResult) {
if result.serial != controller.serial || errors.Is(result.ctx.Err(), context.Canceled) || controller.gate.Locked() || controller.gate.Suspended() {
return
}
text := fmt.Sprintf("Copied %s to clipboard.", result.text)
if result.err != nil {
text = "Error accessing clipboard"
}
controller.present(text, false)
controller.cancelCopyRequest()
}
func (controller *messagingController) cancelCopyRequest() {
controller.serial++
if controller.cancelCopy != nil {
controller.cancelCopy()
controller.cancelCopy = nil
}
}
func (controller *messagingController) reset() {
controller.cancelCopyRequest()
controller.history.Reset()
}
func (controller *messagingController) stop() {
controller.reset()
controller.cancelWorker()
<-controller.done
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/messaging"
)
func pidginMessageEvent(text string) atspi.Event {
event := atspi.NewEvent(atspi.TextInsertedEvent, atspi.Snapshot{BusName: ":1.42", Path: "/transcript", Role: "text", ApplicationProcess: "pidgin"})
event.Window = atspi.NewObjectID(":1.42", "/window")
event.ChatMessage = &messaging.Message{Provider: ":1.42", Conversation: "/transcript", Window: "/window", RoomName: "Conversation", Text: text}
event.ForegroundMismatch = true
return event
}
func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event)}
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
requests := make(chan input.Command)
done := make(chan error, 1)
go func() {
done <- processEventsWithReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, requests, 0)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/entry")
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = "pidgin", "entry", "Message input"
focus.Source.Editable, focus.Source.Focused, focus.Detail1 = true, true, 1
adapter.events <- focus
expectActivationSpeech(t, queue.spoken, "Message input, entry")
adapter.events <- pidginMessageEvent("First message")
expectActivationSpeech(t, queue.spoken, "First message")
adapter.events <- pidginMessageEvent("Second message")
expectActivationSpeech(t, queue.spoken, "Second message")
requests <- input.MessageCommand(0, false)
expectActivationSpeech(t, queue.spoken, "Second message")
requests <- input.MessageCommand(1, false)
expectActivationSpeech(t, queue.spoken, "First message")
requests <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Message input, entry")
// Background chat never becomes current focus, and another provider cannot
// borrow a trusted message's copied metadata.
forged := pidginMessageEvent("Do not announce")
forged.Source.ApplicationProcess = "chromium"
adapter.events <- forged
requests <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Message input, entry")
select {
case text := <-queue.spoken:
t.Fatalf("unexpected output: %q", text)
default:
}
}
func TestMessagingCopyResultsAndSecurityCancellation(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) {
queue := &learnSpeechRecorder{}
copied := make(chan string, 1)
controller := newMessagingController(context.Background(), queue, nil, nil, func(_ context.Context, text string) error {
copied <- text
if fail {
return errors.New("clipboard failed")
}
return nil
})
defer controller.stop()
event := pidginMessageEvent("Unicode 界\nsecond line")
controller.handle(event)
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
select {
case result := <-controller.results:
controller.copied(result)
case <-time.After(time.Second):
t.Fatal("copy did not finish")
}
if text := <-copied; text != event.ChatMessage.Text {
t.Fatalf("clipboard text = %q", text)
}
want := "Copied Unicode 界\nsecond line to clipboard."
if fail {
want = "Error accessing clipboard"
}
if got := queue.texts[len(queue.texts)-1]; got != want {
t.Fatalf("copy response = %q", got)
}
})
}
gate := &focusGate{}
queue := &learnSpeechRecorder{}
started := make(chan struct{})
finished := make(chan struct{})
controller := newMessagingController(context.Background(), queue, gate, nil, func(ctx context.Context, text string) error {
close(started)
<-ctx.Done()
close(finished)
return ctx.Err()
})
defer controller.stop()
controller.handle(pidginMessageEvent("Pending copy"))
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("copy worker blocked event loop")
}
controller.reset()
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("copy did not cancel")
}
if len(queue.texts) != 1 {
t.Fatal("cancelled copy announced completion")
}
controller.review(input.MessageCommand(0, false), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
if len(queue.texts) != 1 {
t.Fatal("cleared history was replayed")
}
}
func TestMessagingSuppressesTypingAndBypassesOnlySuspension(t *testing.T) {
queue := &learnSpeechRecorder{}
gate := &focusGate{}
gate.SetSuspended(true)
braille := &flashRecorder{}
controller := newMessagingController(context.Background(), gatedSpeech{speech: queue, gate: gate}, gate, nil, writeClipboardText)
controller.braille = braille
defer controller.stop()
event := pidginMessageEvent("Incoming message")
if !controller.handle(event) || len(queue.texts) != 1 || len(braille.messages) != 1 {
t.Fatal("background message did not reach speech and Braille")
}
event.ChatMessage.Typing = true
controller.handle(event)
if len(queue.texts) != 1 {
t.Fatal("typing status was announced")
}
if _, ok := controller.history.Previous(":1.42", 1); ok {
t.Fatal("typing status entered history")
}
gate.secure.Store(true)
event.ChatMessage.Typing = false
controller.handle(event)
if len(queue.texts) != 1 || len(braille.messages) != 1 {
t.Fatal("locked message escaped output gate")
}
}
func TestNativeMessagingCopyPreservesClipboardText(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_MESSAGING") != "1" {
t.Skip("requires isolated Xorg clipboard")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
speech := &learnSpeechRecorder{}
controller := newMessagingController(ctx, speech, nil, nil, writeClipboardText)
defer controller.stop()
text := "Username: one\ntwo 界"
controller.handle(pidginMessageEvent(text))
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
select {
case result := <-controller.results:
if result.err != nil {
t.Fatal(result.err)
}
controller.copied(result)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
got, err := readClipboardTarget(ctx, "UTF8_STRING")
if err != nil || got != text {
t.Fatalf("clipboard = %q, %v", got, err)
}
if speech.texts[len(speech.texts)-1] != "Copied "+text+" to clipboard." {
t.Fatal("successful copy was not confirmed")
}
}
func TestMessagingOutputQueueFlashesOnce(t *testing.T) {
live := &liveSettings{}
live.Store(config.DefaultSettings())
speech, braille := &learnSpeechRecorder{}, &flashRecorder{}
controller := newMessagingController(context.Background(), outputQueue{speech: speech, braille: braille, live: live}, nil, live, writeClipboardText)
controller.braille = braille
defer controller.stop()
controller.handle(pidginMessageEvent("One message"))
if len(braille.messages) != 1 || len(speech.texts) != 1 {
t.Fatal("message was duplicated by shared output")
}
}
func TestMessagingQueuedCopyKeepsNewestMessage(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
started := make(chan string, 3)
release := make(chan struct{})
controller := newMessagingController(ctx, &learnSpeechRecorder{}, nil, nil, func(writeCtx context.Context, text string) error {
started <- text
if text == "First" {
select {
case <-release:
case <-ctx.Done():
}
}
return writeCtx.Err()
})
defer controller.stop()
current := atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"}
controller.handle(pidginMessageEvent("First"))
controller.review(input.MessageCommand(0, true), current)
select {
case <-started:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
for _, text := range []string{"Second", "Third"} {
controller.handle(pidginMessageEvent(text))
controller.review(input.MessageCommand(0, true), current)
}
close(release)
select {
case result := <-controller.results:
if result.text != "Third" {
t.Fatalf("completed obsolete copy %q", result.text)
}
case <-ctx.Done():
t.Fatal("newest queued copy was lost")
}
if text := <-started; text != "Third" {
t.Fatalf("copied obsolete queued message %q", text)
}
}
+6 -1
View File
@@ -271,7 +271,12 @@ func (worker commandRuntime) run(ctx context.Context) error {
}
}
default:
if target, level, backwards := documentReviewCommand(command); target != 0 {
if _, _, ok := input.MessageCommandIndex(command); ok {
select {
case reviewRequests <- command:
default:
}
} else if target, level, backwards := documentReviewCommand(command); target != 0 {
if err := objectReview.MoveDocument(target, level, backwards); err != nil {
logger.Warn("queue document navigation", "error", err)
}
+1 -1
View File
@@ -47,7 +47,7 @@ func TestCommandRuntimeRoutesFocusPresentationRequests(t *testing.T) {
t.Error("command worker did not stop")
}
}()
for _, command := range []input.Command{input.PresentCurrentLine, input.SpellCurrentLine, input.PhoneticCurrentLine, input.WhereAmI, input.PresentWindowTitle, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn} {
for _, command := range []input.Command{input.PresentCurrentLine, input.SpellCurrentLine, input.PhoneticCurrentLine, input.WhereAmI, input.PresentWindowTitle, input.PresentCellFormula, input.SetColumnHeadersRow, input.ClearColumnHeadersRow, input.SetRowHeadersColumn, input.ClearRowHeadersColumn, input.MessageCommand(0, false), input.MessageCommand(8, true)} {
commands.commands <- input.CommandEvent{Command: command}
select {
case got := <-requests:
-5
View File
@@ -130,8 +130,3 @@ func clipboardPNG(ctx context.Context) (tentaclewire.Item, error) {
keep = true
return tentaclewire.Item{Path: path, Temporary: true}, nil
}
func copyTentacleText(ctx context.Context, value string) error {
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in")
command.Stdin = strings.NewReader(value)
return command.Run()
}
+1 -1
View File
@@ -66,7 +66,7 @@ func TestNativeTentacleClipboardTargetsAndTemporaryImage(t *testing.T) {
t.Fatal("accepted multiple file-manager clipboard items")
}
text := "one\ntwo é猫"
if err = copyTentacleText(ctx, text); err != nil {
if err = writeClipboardText(ctx, text); err != nil {
t.Fatal(err)
}
item, err = readTentacleClipboard(ctx)
+2 -2
View File
@@ -55,7 +55,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
}
if value.Clipboard != nil {
copyCtx, stopCopy := context.WithTimeout(ctx, time.Second)
err = copyTentacleText(copyCtx, *value.Clipboard)
err = writeClipboardText(copyCtx, *value.Clipboard)
stopCopy()
if err != nil {
value.Presentation = clipboardWriteFailure(value.Presentation)
@@ -207,7 +207,7 @@ func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command te
return internalUIResult{Cancelled: true}, nil
}
copyCtx, stopCopy := context.WithTimeout(ctx, time.Second)
copyErr := copyTentacleText(copyCtx, *status.Clipboard)
copyErr := writeClipboardText(copyCtx, *status.Clipboard)
stopCopy()
if copyErr != nil {
status.Presentation = clipboardWriteFailure(status.Presentation)
+161
View File
@@ -0,0 +1,161 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/logging"
)
type echoRoutingSpeaker struct{ orderedSpeaker }
func (speaker *echoRoutingSpeaker) SpeakVoice(_ context.Context, text, voice string) error {
speaker.calls = append(speaker.calls, voice+":"+text)
return nil
}
func (speaker *echoRoutingSpeaker) SpeakCharacter(ctx context.Context, text string) error {
return speaker.SpeakVoice(ctx, text, "character")
}
func (speaker *echoRoutingSpeaker) SpeakEcho(ctx context.Context, text string) error {
return speaker.SpeakVoice(ctx, text, "echo")
}
func (speaker *echoRoutingSpeaker) SpeakEchoCharacter(ctx context.Context, text string) error {
return speaker.SpeakVoice(ctx, text, "echo-character")
}
func TestTypingEchoVoiceCombinations(t *testing.T) {
for modes := 0; modes < 8; modes++ {
for voices := 0; voices < 16; voices++ {
for _, custom := range []bool{false, true} {
t.Run(fmt.Sprintf("modes=%03b/voices=%04b/custom=%v", modes, voices, custom), func(t *testing.T) {
settings := config.DefaultSettings()
settings.EnableEchoByCharacter = modes&1 != 0
settings.EnableEchoByWord = modes&2 != 0
settings.EnableEchoBySentence = modes&4 != 0
settings.UseCustomEchoVoice = custom
settings.UseCustomEchoForKey = voices&1 != 0
settings.UseCustomEchoForCharacter = voices&2 != 0
settings.UseCustomEchoForWord = voices&4 != 0
settings.UseCustomEchoForSentence = voices&8 != 0
live := &liveSettings{}
live.Store(settings)
queue := &remoteSpeechQueue{}
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
for _, tc := range []struct {
payload, context, basic, word, sentence string
}{
{"é", "Café", "é", "", ""},
{" ", "Café ", "space", "Café", ""},
{",", "Café,", ",", "Café", ""},
{" ", "First. Second. ", "space", "", "Second."},
{"pasted text", "pasted text", "pasted text", "", ""},
} {
want, voiceBit, character := tc.basic, 1, true
if settings.EnableEchoByCharacter {
voiceBit = 2
}
if settings.EnableEchoByWord && tc.word != "" {
want, voiceBit, character = tc.word, 4, false
}
if settings.EnableEchoBySentence && tc.sentence != "" {
want, voiceBit, character = tc.sentence, 8, false
}
if tc.payload == "pasted text" {
voiceBit, character = 0, false
}
queue.requests = nil
presenter.speak(atspi.Event{Type: atspi.TextInsertedEvent, Payload: tc.payload,
Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: tc.context}})
if len(queue.requests) != 1 {
t.Fatalf("payload=%q: requests=%+v", tc.payload, queue.requests)
}
request := queue.requests[0]
wantCustom := custom && voices&voiceBit != 0
if request.Text != want || request.CustomEcho != wantCustom || request.Character != character {
t.Fatalf("payload=%q: request=%+v; want text=%q custom=%v character=%v", tc.payload, request, want, wantCustom, character)
}
speaker := &echoRoutingSpeaker{}
var output bytes.Buffer
worker := newSpeechWorker(logging.New(&output, logging.Options{Level: slog.LevelDebug}), speaker)
if err := worker.deliver(t.Context(), request); err != nil {
t.Fatal(err)
}
voice := ""
if wantCustom {
voice = "echo"
}
if character {
if voice != "" {
voice += "-"
}
voice += "character"
}
if got := fmt.Sprint(speaker.calls); got != "["+voice+":"+want+"]" {
t.Fatalf("backend calls=%s", got)
}
var record struct {
Utterance string `json:"utterance"`
CustomEcho *bool `json:"custom_echo"`
}
if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &record); err != nil {
t.Fatalf("delivery log: %v: %s", err, output.String())
}
if record.Utterance != want || record.CustomEcho == nil || *record.CustomEcho != wantCustom {
t.Fatalf("incorrect voice in delivery log: %s", output.String())
}
}
})
}
}
}
}
func TestTypingEchoLineBreakKeepsPriorCaretContext(t *testing.T) {
for _, mode := range []string{"word", "sentence"} {
for _, before := range []string{"Hello", ""} {
t.Run(mode+"/before="+before, func(t *testing.T) {
settings := config.DefaultSettings()
settings.EnableEchoByWord = mode == "word"
settings.EnableEchoBySentence = mode == "sentence"
settings.UseCustomEchoVoice = true
settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = true, true
live := &liveSettings{}
live.Store(settings)
queue := &remoteSpeechQueue{}
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
source := atspi.Snapshot{BusName: ":1.42", Path: "/editor", Role: "entry", Editable: true,
Text: before + "following text", TextBeforeCaret: before, TextCaretKnown: true}
presenter.speak(atspi.NewEvent(atspi.FocusEvent, source))
queue.requests = nil
source.Text, source.TextBeforeCaret = "following text", ""
presenter.speak(atspi.NewEventWithPayload(atspi.TextInsertedEvent, source, "\n"))
if before == "" {
if len(queue.requests) != 0 {
t.Fatalf("following text echoed at start of line: %+v", queue.requests)
}
return
}
if len(queue.requests) != 1 || queue.requests[0].Text != before || !queue.requests[0].CustomEcho {
t.Fatalf("completed %s: %+v", mode, queue.requests)
}
if strings.Contains(presenter.state.typingText, "following") {
t.Fatalf("following line entered prior-caret cache: %q", presenter.state.typingText)
}
})
}
}
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bufio"
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
)
func TestNativeTypingEchoLineSplit(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TYPING_ECHO") != "1" {
t.Skip("requires native_test_session.py private GTK session")
}
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
previousBus := ""
for _, mode := range []string{"word", "sentence"} {
for _, offset := range []string{"5", "0"} {
t.Run(mode+"/offset="+offset, func(t *testing.T) {
fixture := exec.CommandContext(ctx, "/usr/bin/python3", filepath.Join("..", "..", "tests", "gtk_typing_echo.py"), offset)
output, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
fixture.Stderr = os.Stderr
if err := fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() { fixture.Process.Kill(); fixture.Wait() }()
if line, err := bufio.NewReader(output).ReadString('\n'); err != nil || line != "READY\n" {
t.Fatalf("GTK fixture readiness: %q %v", line, err)
}
keys := func(args ...string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %v: %s", err, out)
}
}
keys("search", "--sync", "--onlyvisible", "--pid", strconv.Itoa(fixture.Process.Pid), "--name", "^Typing echo probe$", "windowfocus", "--sync")
var focus atspi.Event
for focus.Source.Name != "Echo editor" || focus.Source.BusName == previousBus || !isFocusGainEvent(focus) {
select {
case focus = <-adapter.Events():
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
previousBus = focus.Source.BusName
want := "Hello"
if offset == "0" {
want = ""
}
if !focus.Source.TextCaretKnown || focus.Source.TextBeforeCaret != want {
t.Fatalf("initial native caret context: %+v", focus.Source)
}
settings := config.DefaultSettings()
settings.EnableEchoByWord = mode == "word"
settings.EnableEchoBySentence = mode == "sentence"
live := &liveSettings{}
live.Store(settings)
queue := &remoteSpeechQueue{}
presenter := eventPresenter{state: &eventLoopState{}, live: live, speech: queue, logger: slog.New(slog.DiscardHandler)}
presenter.speak(focus)
queue.requests = nil
keys("key", "--clearmodifiers", "Return")
for {
select {
case event := <-adapter.Events():
if event.Source.ID() != focus.Source.ID() || event.Type != atspi.TextInsertedEvent || event.Payload != "\n" {
continue
}
presenter.speak(event)
if want == "" {
if len(queue.requests) != 0 {
t.Fatalf("start of line echoed following text: %+v", queue.requests)
}
} else if len(queue.requests) != 1 || queue.requests[0].Text != want {
t.Fatalf("line split echo=%+v, want %q; native line=%q before=%q", queue.requests, want, event.Source.Text, event.Source.TextBeforeCaret)
}
return
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
})
}
}
}