Messages read as you tab through discord again.
This commit is contained in:
1 parent
6ed0e36960
commit
9374b5a2e5
10 files changed
+162
-3
No files matched your search
@@ -128,3 +128,36 @@ The implementation work does not replace the running reader.
|
|||||||
once and the normal focus display returns afterward.
|
once and the normal focus display returns afterward.
|
||||||
|
|
||||||
Actual Pidgin listening, physical shortcuts, and Braille acceptance remain pending.
|
Actual Pidgin listening, physical shortcuts, and Braille acceptance remain pending.
|
||||||
|
|
||||||
|
## Discord message focus
|
||||||
|
|
||||||
|
Named article ancestors are announced when focus enters their controls, matching
|
||||||
|
Python's article ancestor presentation. Discord supplies the message author,
|
||||||
|
contents, and timestamp in the article's accessible name. The message is spoken
|
||||||
|
once on entry, followed by the focused button. Moving between controls within
|
||||||
|
the same article does not repeat it. Leaving does not repeat the old message.
|
||||||
|
This also works in focus mode and while browser context discovery is pending.
|
||||||
|
Unnamed articles do not add announcements; other container preferences retain
|
||||||
|
their existing browse-mode policy. Native traversal retains its depth and time
|
||||||
|
limits and does not scan message descendants.
|
||||||
|
|
||||||
|
Automated checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app -run '^TestMessageArticleAnnouncedOnceWhenTabbingIntoControls$' -count=1
|
||||||
|
CTHULHU_NATIVE_BROWSER_TESTS=article python3 tests/native_browser.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Both tests fail without the corresponding fix. The private Chromium fixture
|
||||||
|
checks native capture of a message article labelled by author, body, and time.
|
||||||
|
The speech replay checks entry, movement within a message, movement to another
|
||||||
|
message, exit, and re-entry. Neither establishes audible Discord acceptance.
|
||||||
|
|
||||||
|
Before submission, review the diff and test a rebuilt reader in Discord with
|
||||||
|
Tab and Shift+Tab: enter a message's controls, move through Reply/Forward and
|
||||||
|
reactions, then enter another message. Confirm each message is read on entry,
|
||||||
|
control labels remain correct, and message contents do not repeat at each Tab
|
||||||
|
stop. Repeat in browse and focus modes; check the message composer, ordinary
|
||||||
|
browser buttons, and list/panel context announcements for regressions. Verify
|
||||||
|
physical Braille focus and routing separately. Installed-binary, physical-input,
|
||||||
|
audible-speech, and Braille-display acceptance remain pending.
|
||||||
@@ -220,8 +220,22 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
|
|||||||
line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine}
|
line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine}
|
||||||
}
|
}
|
||||||
browseContext := browser.speech_context_enabled() && event.Source.BusName == browser.document.BusName
|
browseContext := browser.speech_context_enabled() && event.Source.BusName == browser.document.BusName
|
||||||
|
context := event.Source.SpeechContext
|
||||||
|
if !browseContext && isFocusGainEvent(event) {
|
||||||
|
// Named articles label a focused descendant even in focus mode (for
|
||||||
|
// example, a chat message's Reply button), including while browser
|
||||||
|
// context discovery is pending. Other container context retains its
|
||||||
|
// browse-mode policy.
|
||||||
|
context = nil
|
||||||
|
for _, ancestor := range event.Source.SpeechContext {
|
||||||
|
if ancestor.Role == "article" && ancestor.Name != "" {
|
||||||
|
context = append(context, ancestor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
browseContext = len(context) != 0
|
||||||
|
}
|
||||||
speech.Enqueue(speechRequest{
|
speech.Enqueue(speechRequest{
|
||||||
Context: event.Source.SpeechContext, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext),
|
Context: context, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext),
|
||||||
Window: eventWindow(event),
|
Window: eventWindow(event),
|
||||||
IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) &&
|
IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) &&
|
||||||
activationTitle(event) != "",
|
activationTitle(event) != "",
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say
|
|||||||
enabled = presentation.SayAllContextEnabled
|
enabled = presentation.SayAllContextEnabled
|
||||||
}
|
}
|
||||||
for _, item := range previous {
|
for _, item := range previous {
|
||||||
if !contains(current, item) && enabled(presentation.ContextType(item), settings) {
|
// Article labels can contain an entire chat message. Python announces
|
||||||
|
// them on entry only; repeating the label on exit obscures the next one.
|
||||||
|
if item.Role != "article" && !contains(current, item) && enabled(presentation.ContextType(item), settings) {
|
||||||
appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".")
|
appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,3 +94,41 @@ func TestReviewContextSuppressedOutsideBrowseMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessageArticleAnnouncedOnceWhenTabbingIntoControls(t *testing.T) {
|
||||||
|
for name, mode := range map[string]review.DocumentMode{"browse": review.BrowseMode, "focus": review.FocusMode, "resolving": review.InactiveMode} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
|
||||||
|
speaker := &spellingTestSpeaker{}
|
||||||
|
worker := newSpeechWorker(logger, speaker)
|
||||||
|
worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading }
|
||||||
|
browser := &browserModeController{ready: mode != review.InactiveMode, mode: mode, document: atspi.NewObjectID(":1.42", "/document")}
|
||||||
|
state := &eventLoopState{}
|
||||||
|
state.presentationOptions = presentation.DefaultOptions()
|
||||||
|
presenter := eventPresenter{state: state, logger: logger, speech: queue, browser: browser}
|
||||||
|
article := atspi.Snapshot{BusName: ":1.42", Path: "/message1", Role: "article", Name: "Author: First message"}
|
||||||
|
check := func(path, name string, ancestors []atspi.Snapshot, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
control := atspi.Snapshot{BusName: ":1.42", Path: path, Role: "button", Name: name, SpeechContext: ancestors}
|
||||||
|
presenter.speak(atspi.NewEvent(atspi.FocusEvent, control))
|
||||||
|
speaker.parts = nil
|
||||||
|
if len(queue.requests) != 1 {
|
||||||
|
t.Fatalf("queued %d requests", len(queue.requests))
|
||||||
|
}
|
||||||
|
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(speaker.parts, want) {
|
||||||
|
t.Fatalf("speech = %q, want %q", speaker.parts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("/reply1", "Reply", []atspi.Snapshot{article}, []string{"Author: First message article", "Reply", "button"})
|
||||||
|
check("/forward1", "Forward", []atspi.Snapshot{article}, []string{"Forward", "button"})
|
||||||
|
article.Path, article.Name = "/message2", "Author: Second message"
|
||||||
|
check("/reply2", "Reply", []atspi.Snapshot{article}, []string{"Author: Second message article", "Reply", "button"})
|
||||||
|
check("/outside", "Outside", []atspi.Snapshot{}, []string{"Outside", "button"})
|
||||||
|
check("/reply2", "Reply", []atspi.Snapshot{article}, []string{"Author: Second message article", "Reply", "button"})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package atspi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeBrowserArticleContext(t *testing.T) {
|
||||||
|
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||||
|
t.Skip("requires private browser session")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
adapter := New()
|
||||||
|
if err := adapter.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
stop, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := adapter.Stop(stop); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if out, err := exec.CommandContext(ctx, "xdotool", "key", "F9").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("focus: %s: %v", out, err)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("missing article control focus", ctx.Err())
|
||||||
|
case event := <-adapter.Events():
|
||||||
|
if (event.Type != FocusEvent && event.Type != FocusedStateEvent) || event.Source.Name != "Reply" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ancestor := range event.Source.SpeechContext {
|
||||||
|
if ancestor.Role == "article" && ancestor.Name == "Sample author A message with readable contents. Today at noon" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("message article missing from speech context: %+v", event.Source.SpeechContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ func native_speech_context(source *C.AtspiAccessible) ([]Snapshot, []Snapshot) {
|
|||||||
}
|
}
|
||||||
if speechOpen {
|
if speechOpen {
|
||||||
switch role {
|
switch role {
|
||||||
case "block quote", "blockquote", "list", "list box", "table", "tree table", "panel", "grouping", "landmark", "form", "section", "document spreadsheet", "link":
|
case "article", "block quote", "blockquote", "list", "list box", "table", "tree table", "panel", "grouping", "landmark", "form", "section", "document spreadsheet", "link":
|
||||||
if (role == "list" || role == "list box") && time.Now().Before(deadline) {
|
if (role == "list" || role == "list box") && time.Now().Before(deadline) {
|
||||||
var childError *C.GError
|
var childError *C.GError
|
||||||
count := C.atspi_accessible_get_child_count(current, &childError)
|
count := C.atspi_accessible_get_child_count(current, &childError)
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ func FullRowEnabled(snapshot atspi.Snapshot, options Options) bool {
|
|||||||
// ContextType matches Python's separate container and landmark preferences.
|
// ContextType matches Python's separate container and landmark preferences.
|
||||||
func ContextType(snapshot atspi.Snapshot) string {
|
func ContextType(snapshot atspi.Snapshot) string {
|
||||||
switch snapshot.Role {
|
switch snapshot.Role {
|
||||||
|
case "article":
|
||||||
|
if snapshot.Name != "" {
|
||||||
|
return "article"
|
||||||
|
}
|
||||||
case "block quote", "blockquote":
|
case "block quote", "blockquote":
|
||||||
return "blockquote"
|
return "blockquote"
|
||||||
case "list", "list box":
|
case "list", "list box":
|
||||||
@@ -109,6 +113,8 @@ func ContextEnabled(kind string, reading config.ReadingSettings) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
|
case "article":
|
||||||
|
return true
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
return reading.SpeakContextBlockquote
|
return reading.SpeakContextBlockquote
|
||||||
case "list":
|
case "list":
|
||||||
@@ -131,6 +137,8 @@ func SayAllContextEnabled(kind string, reading config.ReadingSettings) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
|
case "article":
|
||||||
|
return true
|
||||||
case "blockquote":
|
case "blockquote":
|
||||||
return reading.SayAllContextBlockquote
|
return reading.SayAllContextBlockquote
|
||||||
case "list":
|
case "list":
|
||||||
|
|||||||
Vendored
+1
@@ -72,3 +72,4 @@ mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/
|
|||||||
speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences",,internal/input internal/config internal/preferences internal/app,implemented,go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1,"Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload.",Physical compositor acceptance pending.,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Physical display acceptance pending.,All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile.
|
speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences",,internal/input internal/config internal/preferences internal/app,implemented,go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1,"Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload.",Physical compositor acceptance pending.,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Physical display acceptance pending.,All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile.
|
||||||
pidgin-message-history,src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py,messaging,Announce verified Pidgin transcript insertions and review or copy nine recent messages,Storm Dragon,internal/messaging internal/atspi internal/app internal/input,implemented,go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks,Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending.,,Recorded presentation tested; actual Pidgin listening pending.,Single flash recorded; physical Braille pending.,"Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md."
|
pidgin-message-history,src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py,messaging,Announce verified Pidgin transcript insertions and review or copy nine recent messages,Storm Dragon,internal/messaging internal/atspi internal/app internal/input,implemented,go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks,Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending.,,Recorded presentation tested; actual Pidgin listening pending.,Single flash recorded; physical Braille pending.,"Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md."
|
||||||
atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py,focus,"Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership",,internal/atspi internal/app,implemented,"go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape",Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged,"Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31.","Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31.",Braille flash follows the system-role output queue; physical display acceptance pending.,"Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md."
|
atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py,focus,"Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership",,internal/atspi internal/app,implemented,"go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape",Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged,"Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31.","Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31.",Braille flash follows the system-role output queue; physical display acceptance pending.,"Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md."
|
||||||
|
web-message-article-focus,src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py,web,Announce named message articles when focus enters their controls,,internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go,in-progress,Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md.,Private Chromium fixture uses isolated X11 and D-Bus.,,Recorded speech only; live Discord listening pending.,Physical display acceptance pending.,Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit.
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Cthulhu Browser Probe</title>
|
||||||
|
<ol>
|
||||||
|
<li><article aria-labelledby="author message timestamp">
|
||||||
|
<h3><button id="author">Sample author</button> <time id="timestamp">Today at noon</time></h3>
|
||||||
|
<div id="message">A message with readable contents.</div>
|
||||||
|
<button id="reply">Reply</button><button>Forward</button>
|
||||||
|
</article></li>
|
||||||
|
</ol>
|
||||||
|
<script>document.addEventListener('keydown', e => { if (e.key === 'F9') document.getElementById('reply').focus(); });</script>
|
||||||
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
|
|||||||
|
|
||||||
|
|
||||||
BROWSER_PROOFS = {
|
BROWSER_PROOFS = {
|
||||||
|
"article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"),
|
||||||
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
|
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
|
||||||
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),
|
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),
|
||||||
"disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"),
|
"disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"),
|
||||||
|
|||||||
Reference in new issue
Block a user