diff --git a/docs/testing/messaging.md b/docs/testing/messaging.md index cdcf481..643eb32 100644 --- a/docs/testing/messaging.md +++ b/docs/testing/messaging.md @@ -128,3 +128,36 @@ The implementation work does not replace the running reader. once and the normal focus display returns afterward. 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. diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index 2005e40..2c3af7b 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -220,8 +220,22 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine} } 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{ - 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), IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) && activationTitle(event) != "", diff --git a/internal/app/reading_context.go b/internal/app/reading_context.go index a35145a..6f7f650 100644 --- a/internal/app/reading_context.go +++ b/internal/app/reading_context.go @@ -59,7 +59,9 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say enabled = presentation.SayAllContextEnabled } 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)+".") } } diff --git a/internal/app/reading_context_test.go b/internal/app/reading_context_test.go index 88a427b..3fcd995 100644 --- a/internal/app/reading_context_test.go +++ b/internal/app/reading_context_test.go @@ -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"}) + }) + } +} diff --git a/internal/atspi/article_native_test.go b/internal/atspi/article_native_test.go new file mode 100644 index 0000000..0503fad --- /dev/null +++ b/internal/atspi/article_native_test.go @@ -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) + } + } +} diff --git a/internal/atspi/speech_metadata_native.go b/internal/atspi/speech_metadata_native.go index e7910e2..a64aa96 100644 --- a/internal/atspi/speech_metadata_native.go +++ b/internal/atspi/speech_metadata_native.go @@ -110,7 +110,7 @@ func native_speech_context(source *C.AtspiAccessible) ([]Snapshot, []Snapshot) { } if speechOpen { 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) { var childError *C.GError count := C.atspi_accessible_get_child_count(current, &childError) diff --git a/internal/presentation/reading.go b/internal/presentation/reading.go index cfc9f14..893769a 100644 --- a/internal/presentation/reading.go +++ b/internal/presentation/reading.go @@ -69,6 +69,10 @@ func FullRowEnabled(snapshot atspi.Snapshot, options Options) bool { // ContextType matches Python's separate container and landmark preferences. func ContextType(snapshot atspi.Snapshot) string { switch snapshot.Role { + case "article": + if snapshot.Name != "" { + return "article" + } case "block quote", "blockquote": return "blockquote" case "list", "list box": @@ -109,6 +113,8 @@ func ContextEnabled(kind string, reading config.ReadingSettings) bool { return false } switch kind { + case "article": + return true case "blockquote": return reading.SpeakContextBlockquote case "list": @@ -131,6 +137,8 @@ func SayAllContextEnabled(kind string, reading config.ReadingSettings) bool { return false } switch kind { + case "article": + return true case "blockquote": return reading.SayAllContextBlockquote case "list": diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 70690f2..531d8d7 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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. 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." +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. diff --git a/tests/browser-article.html b/tests/browser-article.html new file mode 100644 index 0000000..bb1a1a8 --- /dev/null +++ b/tests/browser-article.html @@ -0,0 +1,11 @@ + + +Cthulhu Browser Probe +
    +
  1. +

    +
    A message with readable contents.
    + +
  2. +
+ diff --git a/tests/native_browser.py b/tests/native_browser.py index 63b2625..6e752ed 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -12,6 +12,7 @@ from native_test_session import run_private_session BROWSER_PROOFS = { + "article": ("./internal/atspi", "^TestNativeBrowserArticleContext$", 30, 35, "browser-article.html"), "emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"), "structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50), "disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"),