Fix for repeating in some instances, e.g. pidgin.

This commit is contained in:
Storm Dragon committed 2026-10-27 19:40:17 -04:00
1 parent 4d29f2bb23
commit b232c1eebb
20 files changed
+921 -7

No files matched your search

+6
View File
@@ -129,6 +129,12 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
state.typingText = event.Source.Text
}
}
if state.submitted_text_clear(event, recentInput(inputSource)) {
// Focus, caret suppression and Braille were already updated by the
// event handler. Only omit speech for the entry's submission clear.
logger.Debug("skipped deletion feedback after submitting text", "role", event.Source.Role)
return
}
if event.Type == atspi.CaretEvent && (intent == input.WordIntent || intent == input.LineIntent) && event.Source.Misspelled &&
settings.Reading.SpeakMisspelledIndicator && !settings.Reading.OnlySpeakDisplayedText {
spoken = presentation.SpeechWithOptions([]string{strings.TrimSuffix(text, ", misspelled"), "misspelled"}, table.options)
+8
View File
@@ -21,6 +21,14 @@ func TestNotificationProvidersAndHistoryMatchReferenceBehavior(t *testing.T) {
expectedSpeech []string
expectedHistory string
}{
{
name: "MATE notification daemon labels",
event: notificationSnapshot("mate-notification-daemon", atspi.WindowCreateEvent, atspi.NewSnapshot(
"/notification", "", "frame", "", "Mail arrived New message",
)),
expectedSpeech: []string{"Notification Mail arrived New message"},
expectedHistory: "Notification Mail arrived New message",
},
{
name: "xfce notification daemon labels",
event: notificationSnapshot("xfce4-notifyd", atspi.WindowCreateEvent, atspi.NewSnapshot(
+111
View File
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
)
func TestNativeMatePanelMenuPresentation(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_PANEL_MENU") != "1" {
t.Skip("requires private Xorg/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
start := func(name string) (io.WriteCloser, string) {
t.Helper()
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_panel_menu.py", name)
input, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
command.Stderr = os.Stderr
if err := command.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() })
line, err := bufio.NewReader(output).ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 2 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
return input, fields[1]
}
panel, _ := start("mate-panel")
editor, xid := start("editor")
key := func(args ...string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %s, %v", output, err)
}
}
key("windowfocus", "--sync", xid)
speech := &fakeSpeechQueue{spoken: make(chan string, 128)}
frames := make(chan braille.Frame, 128)
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler),
outputQueue{speech: speech, braille: browserBrailleProbe{frames: frames}},
adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done }()
wait := func(name string) {
t.Helper()
spoken, displayed := false, false
var messages []string
for !spoken || !displayed {
select {
case text := <-speech.spoken:
messages = append(messages, text)
spoken = spoken || strings.Contains(text, name)
case frame := <-frames:
displayed = displayed || strings.Contains(frame.Text, name)
case <-ctx.Done():
t.Fatalf("missing presentation %q: speech=%v braille=%v messages=%q", name, spoken, displayed, messages)
}
}
}
fmt.Fprintln(editor, "edit Foreground ready")
wait("Foreground ready")
for iteration := 0; iteration < 2; iteration++ {
fmt.Fprintln(panel, "popup")
wait("menu")
// The initial bare menu may have no label. Navigation must acquire
// its first item, including after dismissal and reopening.
for _, step := range []struct{ key, name string }{
{"Down", "First application"}, {"Down", "Utilities"}, {"Right", "Example utility"},
{"Left", "Utilities"}, {"Down", "Last application"},
} {
key("key", step.key)
wait(step.name)
}
key("key", "Escape")
text := fmt.Sprintf("Returned to editor %d", iteration)
fmt.Fprintln(editor, "edit "+text)
wait(text)
}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
)
// A submission can clear an entry before its message appears in a transcript.
// Do not mistake that clear for an editing command. Unknown input/caret data,
// other controls and selected-text replacement keep their existing feedback.
func (state *eventLoopState) submitted_text_clear(event atspi.Event, activity input.Activity) bool {
source := event.Source
if event.Type != atspi.TextDeletedEvent || event.Payload == "" || event.Detail1 != 0 ||
!activity.PlainEnter || activity.Sequence == 0 || !atspi.IsEchoEditField(source) ||
(source.Role != "text" && source.Role != "entry") || source.Autocomplete || source.LiveRegion ||
source.ID() != state.focusedID || !source.TextCaretKnown || source.BrailleCaret != 0 ||
source.BrailleLineStart != 0 || source.Text != "" || source.TextBeforeCaret != "" || source.BrailleLine != "" ||
source.SelectionEnd > source.SelectionStart {
return false
}
if state.selection.id == source.ID() && state.selection.end > state.selection.start {
return false
}
// Some providers report selection removal before the deletion itself.
return state.selectionCaretID != source.ID() || state.selectionCaretSequence != activity.Sequence
}
+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"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
func TestNativeSubmissionClearAndSelectedReplacement(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_SUBMISSION_ECHO") != "1" {
t.Skip("requires native_test_session.py private GTK session")
}
ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
listener := xorg.New()
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
previousBus := ""
for _, mode := range []string{"submit", "replace"} {
t.Run(mode, func(t *testing.T) {
fixture := exec.CommandContext(ctx, "/usr/bin/python3", filepath.Join("..", "..", "tests", "gtk_submit_echo.py"), mode)
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", "^Submission echo probe$", "windowfocus", "--sync")
var focus atspi.Event
for focus.Source.Name != "Message input" || focus.Source.BusName == previousBus || !isFocusGainEvent(focus) {
select {
case focus = <-adapter.Events():
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
previousBus = focus.Source.BusName
keys("key", "--clearmodifiers", "Return")
inputLoop:
for {
select {
case key := <-listener.Events():
if key.Pressed && key.Name == "Return" {
break inputLoop
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
for {
select {
case event := <-adapter.Events():
if event.Source.ID() != focus.Source.ID() || event.Type != atspi.TextDeletedEvent {
continue
}
if !listener.RecentInput().PlainEnter {
t.Fatal("native X11 Enter activity missing")
}
got := submitted_text_speech(t, listener, focus, event)
if mode == "submit" && len(got) != 1 {
t.Fatalf("native submission echoed deletion: %q; snapshot=%+v", got, event.Source)
}
if mode == "replace" && (len(got) != 2 || got[1] != "A sample message.") {
t.Fatalf("native selected replacement lost feedback: %q", got)
}
t.Logf("native %s: %q", mode, got)
return
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
})
}
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"slices"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestSubmittedMessageIsAnnouncedOnce(t *testing.T) {
for _, key := range []string{"Return", "KP_Enter"} {
t.Run(key, func(t *testing.T) {
entry := ownerEvent(atspi.FocusEvent, ":1.42", "/window", "/entry")
entry.Source.Role, entry.Source.ApplicationProcess = "text", "pidgin"
entry.Source.Editable, entry.Source.Focused, entry.Source.TextCaretKnown = true, true, true
deletion := atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry.Source, "A sample message.")
deletion.Window = entry.Window
message := pidginMessageEvent("Username: A sample message.")
tracker := &input.IntentTracker{}
tracker.Observe(input.NewKeyEvent(0, true, key))
tracker.Observe(input.NewKeyEvent(0, false, key))
got := submitted_text_speech(t, tracker, entry, deletion,
atspi.NewEvent(atspi.CaretEvent, entry.Source), message)
want := []string{"text", "Username: A sample message."}
if !slices.Equal(got, want) {
t.Fatalf("submission speech = %q, want %q", got, want)
}
})
}
}
func TestSubmissionClearPreservesEditingFeedback(t *testing.T) {
for _, test := range []struct {
name string
keys []string
selected bool
removeFirst bool
insert string
remainingText string
caretKnown bool
want []string
}{
{name: "plain Enter clear", keys: []string{"Return"}, caretKnown: true},
{name: "Backspace", keys: []string{"BackSpace"}, caretKnown: true, want: []string{"old"}},
{name: "Delete", keys: []string{"Delete"}, caretKnown: true, want: []string{"old"}},
{name: "word deletion", keys: []string{"Control_L", "BackSpace"}, caretKnown: true, want: []string{"old"}},
{name: "cut", keys: []string{"Control_L", "x"}, caretKnown: true, want: []string{"cut"}},
{name: "paste replacement", keys: []string{"Control_L", "v"}, insert: "new", caretKnown: true, want: []string{"old", "pasted from clipboard"}},
{name: "undo", keys: []string{"Control_L", "z"}, caretKnown: true, want: []string{"old"}},
{name: "redo", keys: []string{"Control_L", "Shift_L", "z"}, caretKnown: true, want: []string{"old"}},
{name: "typing replaces selection", keys: []string{"x"}, selected: true, insert: "x", caretKnown: true, want: []string{"old", "x"}},
{name: "Enter replaces selection", keys: []string{"Return"}, selected: true, insert: "\n", caretKnown: true, want: []string{"old"}},
{name: "selection removal precedes replacement", keys: []string{"Return"}, selected: true, removeFirst: true, caretKnown: true, want: []string{"selection removed", "old"}},
{name: "Shift Enter", keys: []string{"Shift_L", "Return"}, caretKnown: true, want: []string{"old"}},
{name: "unknown caret", keys: []string{"Return"}, want: []string{"old"}},
{name: "no keyboard evidence", caretKnown: true, want: []string{"old"}},
{name: "Enter leaves text", keys: []string{"Return"}, caretKnown: true, remainingText: "remaining", want: []string{"old"}},
} {
t.Run(test.name, func(t *testing.T) {
tracker := &input.IntentTracker{}
for _, key := range test.keys {
tracker.Observe(input.NewKeyEvent(0, true, key))
}
entry := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Role: "entry", Editable: true, Focused: true, TextCaretKnown: test.caretKnown}
if test.selected {
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 3, "old"
}
events := []atspi.Event{atspi.NewEvent(atspi.FocusEvent, entry)}
entry.SelectionStart, entry.SelectionEnd, entry.SelectionText = 0, 0, ""
entry.Text = test.remainingText
if test.removeFirst {
events = append(events, atspi.NewEvent(atspi.TextSelectionChangedEvent, entry))
}
events = append(events, atspi.NewEventWithPayload(atspi.TextDeletedEvent, entry, "old"))
if test.insert != "" {
events = append(events, atspi.NewEventWithPayload(atspi.TextInsertedEvent, entry, test.insert))
}
got := submitted_text_speech(t, tracker, events...)
// Initial focus also presents an existing selection.
got = got[1:]
if !slices.Equal(got, test.want) {
t.Fatalf("editing speech = %q, want %q", got, test.want)
}
})
}
}
func submitted_text_speech(t *testing.T, inputSource any, events ...atspi.Event) []string {
t.Helper()
adapter := &fakeAdapter{events: make(chan atspi.Event, len(events))}
for _, event := range events {
adapter.events <- event
}
close(adapter.events)
queue := &remoteSpeechQueue{}
if err := processEventsWithInput(context.Background(), slog.New(slog.DiscardHandler), queue, adapter,
nil, nil, 0, nil, inputSource, nil, nil); err != nil {
t.Fatal(err)
}
texts := make([]string, 0, len(queue.requests))
for _, request := range queue.requests {
texts = append(texts, request.Text)
}
return texts
}