Messages read as you tab through discord again.

This commit is contained in:
lilmike committed 2026-11-01 14:52:44 -08:00
1 parent 6ed0e36960
commit 9374b5a2e5
10 files changed
+162 -3

No files matched your search

+33
View File
@@ -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.
+15 -1
View File
@@ -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) != "",
+3 -1
View File
@@ -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)+".")
} }
} }
+38
View File
@@ -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"})
})
}
}
+51
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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)
+8
View File
@@ -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":
+1
View File
@@ -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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
72 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.
73 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.
74 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.
75 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.
+11
View File
@@ -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>
+1
View File
@@ -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"),