From eb5b2038d603aca95778521a2970e84d838a83e7 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 27 Oct 2026 00:40:49 -0400 Subject: [PATCH] Some echo tests added. --- README.md | 5 + docs/testing/key-echo.md | 78 ++++++ docs/testing/messaging.md | 108 ++++++++ internal/app/clipboard.go | 7 + internal/app/echo_speaker_test.go | 40 +++ internal/app/event_accessibility.go | 4 + internal/app/event_loop.go | 19 +- internal/app/event_presentation.go | 2 +- internal/app/learn_mode.go | 4 +- internal/app/learn_mode_test.go | 4 +- internal/app/messaging.go | 177 +++++++++++++ internal/app/messaging_test.go | 246 ++++++++++++++++++ internal/app/runtime_commands.go | 7 +- internal/app/runtime_commands_test.go | 2 +- internal/app/tentacle_clipboard.go | 5 - internal/app/tentacle_clipboard_test.go | 2 +- internal/app/tentacle_interaction.go | 4 +- internal/app/typing_echo_matrix_test.go | 161 ++++++++++++ internal/app/typing_echo_native_test.go | 105 ++++++++ .../atspi/adapter_event_callback_native.go | 22 ++ internal/atspi/model.go | 4 + internal/atspi/pidgin_native.go | 115 ++++++++ internal/atspi/pidgin_native_test.go | 109 ++++++++ internal/input/bindings.go | 30 +++ internal/input/commands.go | 5 +- internal/input/messaging.go | 32 +++ internal/input/messaging_test.go | 53 ++++ internal/input/ocr_test.go | 3 + internal/messaging/history.go | 92 +++++++ internal/messaging/history_test.go | 83 ++++++ .../platform/xorg/listener_native_test.go | 17 +- internal/presentation/typing_echo.go | 7 +- .../presentation/typing_echo_matrix_test.go | 99 +++++++ internal/version/version.go | 2 +- testdata/parity-ledger.csv | 3 +- tests/check_speech.py | 13 +- tests/gtk_messaging.py | 70 +++++ tests/gtk_typing_echo.py | 31 +++ 38 files changed, 1739 insertions(+), 31 deletions(-) create mode 100644 docs/testing/key-echo.md create mode 100644 docs/testing/messaging.md create mode 100644 internal/app/messaging.go create mode 100644 internal/app/messaging_test.go create mode 100644 internal/app/typing_echo_matrix_test.go create mode 100644 internal/app/typing_echo_native_test.go create mode 100644 internal/atspi/pidgin_native.go create mode 100644 internal/atspi/pidgin_native_test.go create mode 100644 internal/input/messaging.go create mode 100644 internal/input/messaging_test.go create mode 100644 internal/messaging/history.go create mode 100644 internal/messaging/history_test.go create mode 100644 internal/presentation/typing_echo_matrix_test.go create mode 100644 tests/gtk_messaging.py create mode 100644 tests/gtk_typing_echo.py diff --git a/README.md b/README.md index 3e7d48a..6697e0c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Cthulhu is a Linux screen reader written in Go. - [LibreOffice support](LIBREOFFICE_SUPPORT.md): integration boundaries, initial Calc behavior, remaining work, and verification. - [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification. - [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification. +- [Messaging support](docs/testing/messaging.md): Pidgin message review/copy shortcuts, application boundaries, and verification. - [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance. - [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification. - [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation. @@ -127,6 +128,10 @@ checkbox is needed. Under **Use the custom voice for:**, choose **Key names** These choices select the voice; the corresponding echo settings still control what is spoken. Turn off custom echo voice to use the main speech settings. +Debug speech delivery records include `custom_echo: true` for the custom echo +path and `false` for ordinary speech. See [key echo verification](docs/testing/key-echo.md) +for automated mode/voice tests, the native Enter regression and listening steps. + Key names finish before the resulting navigation or focus announcement starts. For example, Right speaks "right" in the selected key echo voice, followed by the character at the caret in the normal reading voice. A configured interruption diff --git a/docs/testing/key-echo.md b/docs/testing/key-echo.md new file mode 100644 index 0000000..962c27c --- /dev/null +++ b/docs/testing/key-echo.md @@ -0,0 +1,78 @@ +# Key echo verification + +Debug records named `delivering accessible presentation` already include +`utterance` and `custom_echo`. `custom_echo: true` identifies a delivery attempt +using the custom echo path; `false` uses the ordinary voice path, including +existing uppercase, system and hyperlink voice choices. These records describe +what the reader sends to its speech backend, not what was audibly heard. +Normal, non-debug logs omit utterances. Password-role delivery text is redacted. + +Run the repeatable speech check: + +```sh +python3 tests/check_speech.py +``` + +It requires the echo regression tests to pass, alongside the existing queue, +title and interruption checks. It uses private services and silent test speech. +The focused equivalent is: + +```sh +go test -race ./internal/presentation ./internal/app ./internal/input -run 'Echo|Capital|EditorParagraph' -count=1 +``` + +The tests cover: + +- All eight combinations of character, word and sentence echo, with echo + enabled and disabled and printable key categories independently disabled. +- All sixteen combinations of the four custom-voice selections, with the + custom-voice master setting on and off. The matrix checks the resulting + utterance, character/text backend call and `custom_echo` delivery record. +- Word punctuation, repeated delimiters, Unicode, sentence precedence over + word echo, Enter completion and multi-character paste presentation. +- Real SSIP client routing for key, character, word and sentence output, + using private recorded speech endpoints, both directly and through the + configured speaker. Existing checks cover uppercase choices, preferences + Apply, persistence, edit-fields-only, Wine character feedback, key-name + completion ordering and fresh-input interruption. + +Verify Enter against a real GTK TextView and native AT-SPI capture: + +```sh +python3 tests/native_test_session.py env CTHULHU_NATIVE_TYPING_ECHO=1 go test ./internal/app -run '^TestNativeTypingEchoLineSplit$' -count=1 -v -timeout=30s +``` + +This private Xvfb/D-Bus check presses Enter in the middle and at the beginning +of existing text, for word and sentence echo. Only text before Enter may be +completed. A known empty position before the caret must stay empty; the rest +of the line must not be substituted into the completion context. The test +captures native events and checks presentation without starting a reader or +using an audio device. GTK3 Python bindings, Xvfb and xdotool are required. + +Wine's insertion protocol currently supplies individual character labels, +without the text context needed to complete words or sentences. The Wine +tests cover the supported character/key routing; they do not establish Wine +word or sentence echo. Native browser and application behavior also needs +acceptance in the target application. + +After reviewing the diff and rebuilding, listen with the updated reader: + +1. Enable character echo with a distinctive custom character voice. Type + lowercase, capitals, digits, punctuation and spaces. Disable that custom + selection, then the custom-voice master setting, and check normal speech. +2. Enable word echo and type `Hello world, ` slowly. Test with character echo + both on and off. Check the Words custom-voice selection independently. +3. Enable sentence echo and type `First. Second. `, including the spaces. + Repeat with word and character echo enabled together; sentence completion + should use the Sentences voice selection. +4. In a multiline editor, put the caret between `Hello` and ` following text` + and press Enter. Word/sentence completion should say `Hello`, never + `following text`. At the start of a line, Enter must not echo the following + text. Repeat on a blank line and with a line ending in punctuation. +5. Check key echo disabled and edit-fields-only, pasted text, Backspace, + Left/Right navigation, rapid typing and interruption during long speech. + Right's echoed key name should finish before normal caret feedback. + +Automated routing and ordering do not prove audible voice identity, physical +keyboard behavior, Braille output or normal-profile acceptance. Human review +and these listening checks remain required before submission. diff --git a/docs/testing/messaging.md b/docs/testing/messaging.md new file mode 100644 index 0000000..f390250 --- /dev/null +++ b/docs/testing/messaging.md @@ -0,0 +1,108 @@ +# Messaging support + +Pidgin now uses the shared Go messaging history. This first port follows the +legacy defaults: announce inserted messages from all conversations, keep one +combined history for each running Pidgin accessibility provider, omit room-name +prefixes, and suppress typing-status announcements. History contains messages +received while this reader is running; it does not import an existing transcript. + +The legacy controls for separate conversation histories, room-name prefixes, +typing announcements, and message verbosity are not yet ported. + +## Shortcuts + +Both desktop and laptop layouts use: + +- **Cthulhu+F1–F9**: review the newest through ninth-newest message. +- **Cthulhu+Shift+F1–F9**: copy that message and confirm after the write succeeds. +- **Cthulhu+H**: enter or exit learn mode. Cthulhu+F1 no longer opens it. + +Empty history slots are silent. Chat commands act only when Pidgin owns the +reader's current focus. They are configurable in the existing key-binding page. +An explicit shortcut assignment takes precedence over an uncustomized chat +default, preserving existing configurations; the shortcut list reflects this. +Within temporary OCR review, Cthulhu+F5 retains its refresh behavior. +In learn mode, plain F1 still opens documentation, and F2/F3 retain their +existing functions. + +Steam retains its existing notification support: **Cthulhu+N** for the latest +notification and **Cthulhu+Ctrl+N** for the notification list. Neither the legacy +Steam script nor this port provides F-key conversation review for Steam. + +## Implementation boundary + +- `internal/atspi/pidgin_native.go` recognizes the legacy transcript shape: + a noneditable, multiline text object directly inside a scroll pane, belonging + to a provider whose process name is `pidgin`. Its bounded ancestor scan copies + conversation/window identities and the room name. Typing status is identified + from the inserted range's smaller/lighter text attributes. +- `internal/messaging` stores copied messages without native handles or output + dependencies. It bounds each provider's history to nine messages and the number + of retained providers to 32. Provider identities prevent cross-instance replay. +- `internal/app/messaging.go` coordinates passive speech, one Braille flash, + review, and asynchronous clipboard copying on the reader event loop. A bounded + worker uses the shared clipboard writer; writes have a three-second deadline. + The event loop cancels pending copies when foreground ownership is cleared, + when another copy replaces them, on screen lock, and during shutdown. +- `internal/input/messaging.go` defines numbered semantic commands independently + of their physical shortcuts. Existing binding configuration and learn mode + describe the same commands. + +Background chat never becomes focus or review ownership. Lock gates reject +messages; lock transitions clear history. Destruction of a known transcript or +window retires its messages. Ordinary edits, buddy-list activity, and unrelated +providers keep their existing event-filtering behavior. History is never saved. + +Future application support should add its own verified native detector and +explicit dispatch to this shared message contract. Keep transcript identification +out of generic focus, speech, and history code. Steam conversation support needs +inspection of an actual chat accessibility tree before choosing its detector; +notification text alone does not establish conversation identity. + +## Automated checks + +From the repository root: + +```sh +GOCACHE=/tmp/cthulhu-cache go test ./... +GOCACHE=/tmp/cthulhu-cache go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app +python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache CTHULHU_NATIVE_MESSAGING=1 go test ./internal/atspi -run '^TestNativePidginTranscriptCapture$' -count=1 -v +python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache CTHULHU_NATIVE_MESSAGING=1 go test ./internal/app -run '^TestNativeMessagingCopyPreservesClipboardText$' -count=1 -v +python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache CTHULHU_NATIVE_X11=1 go test ./internal/platform/xorg -run '^TestNativeLearnModeCapturesKeysAndRestoresCommands$' -count=1 -v +``` + +Native checks use private X11 and D-Bus sessions. The transcript fixture sets its +own process name to exercise the Pidgin detector; it is not the actual Pidgin +client. These checks verify copied metadata, recorded presentation, synthetic +keys, and clipboard contents. They do not establish audible speech, physical +keyboard behavior, or a connected Braille display. + +## Human review and live acceptance + +Review the diff before submission under the contribution policy. Build a reader +for testing, then load it using the normal authorized reader-replacement procedure. +The implementation work does not replace the running reader. + +1. Open two Pidgin conversations. Receive distinct messages in each and verify + they speak once, including when a different application has focus. Confirm + keyboard focus remains in that other application. +2. Return to Pidgin. Check F1/F2 review the most recent messages across both + conversations. Receive more than nine messages and check the F9 boundary. + Send two identical messages and verify both occupy separate history slots. +3. Use Shift+F1/F2 to copy, then paste into a native editor. Check Unicode and + multiline content. Confirm the copy response follows a successful write. +4. Type in Pidgin's message entry and account/preferences fields. Check normal + echo and editing feedback. Buddy-list updates and remote typing status must + not enter chat history. +5. Switch to another application and check chat commands do not replay Pidgin + history. Close a conversation window and check its messages are retired. + Lock/unlock and verify prior history is cleared and messages received while + locked are not replayed. +6. Check Cthulhu+H enters/exits learn mode, F1/F2 work inside it, and the shortcut + list identifies the chat commands. Check a saved custom F-key assignment still + works and the displaced default is not listed as active. +7. Check OCR F5 refresh, normal browser navigation, and Steam notifications with + their existing review/list commands. On Braille, check each message flashes + once and the normal focus display returns afterward. + +Actual Pidgin listening, physical shortcuts, and Braille acceptance remain pending. diff --git a/internal/app/clipboard.go b/internal/app/clipboard.go index c4be918..b06a26d 100644 --- a/internal/app/clipboard.go +++ b/internal/app/clipboard.go @@ -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() +} diff --git a/internal/app/echo_speaker_test.go b/internal/app/echo_speaker_test.go index 1dbf3ad..95e9729 100644 --- a/internal/app/echo_speaker_test.go +++ b/internal/app/echo_speaker_test.go @@ -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) diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index ee5811e..76fcd51 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -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 diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 36e97e0..84df1d6 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -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() { diff --git a/internal/app/event_presentation.go b/internal/app/event_presentation.go index 31b82d5..18b046e 100644 --- a/internal/app/event_presentation.go +++ b/internal/app/event_presentation.go @@ -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 } } diff --git a/internal/app/learn_mode.go b/internal/app/learn_mode.go index 69e73f6..6cbf480 100644 --- a/internal/app/learn_mode.go +++ b/internal/app/learn_mode.go @@ -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) diff --git a/internal/app/learn_mode_test.go b/internal/app/learn_mode_test.go index 1bc11d9..a4e657d 100644 --- a/internal/app/learn_mode_test.go +++ b/internal/app/learn_mode_test.go @@ -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 diff --git a/internal/app/messaging.go b/internal/app/messaging.go new file mode 100644 index 0000000..e0682cb --- /dev/null +++ b/internal/app/messaging.go @@ -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 +} diff --git a/internal/app/messaging_test.go b/internal/app/messaging_test.go new file mode 100644 index 0000000..e38e896 --- /dev/null +++ b/internal/app/messaging_test.go @@ -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) + } +} diff --git a/internal/app/runtime_commands.go b/internal/app/runtime_commands.go index edbe793..dd62fd0 100644 --- a/internal/app/runtime_commands.go +++ b/internal/app/runtime_commands.go @@ -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) } diff --git a/internal/app/runtime_commands_test.go b/internal/app/runtime_commands_test.go index 43b9fdb..5a1eaf8 100644 --- a/internal/app/runtime_commands_test.go +++ b/internal/app/runtime_commands_test.go @@ -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: diff --git a/internal/app/tentacle_clipboard.go b/internal/app/tentacle_clipboard.go index ca5b7d6..c3d2177 100644 --- a/internal/app/tentacle_clipboard.go +++ b/internal/app/tentacle_clipboard.go @@ -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() -} diff --git a/internal/app/tentacle_clipboard_test.go b/internal/app/tentacle_clipboard_test.go index 3e2c058..7d57854 100644 --- a/internal/app/tentacle_clipboard_test.go +++ b/internal/app/tentacle_clipboard_test.go @@ -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) diff --git a/internal/app/tentacle_interaction.go b/internal/app/tentacle_interaction.go index 2ba2f5e..ebb9f26 100644 --- a/internal/app/tentacle_interaction.go +++ b/internal/app/tentacle_interaction.go @@ -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) diff --git a/internal/app/typing_echo_matrix_test.go b/internal/app/typing_echo_matrix_test.go new file mode 100644 index 0000000..9acc978 --- /dev/null +++ b/internal/app/typing_echo_matrix_test.go @@ -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) + } + }) + } + } +} diff --git a/internal/app/typing_echo_native_test.go b/internal/app/typing_echo_native_test.go new file mode 100644 index 0000000..3c946ab --- /dev/null +++ b/internal/app/typing_echo_native_test.go @@ -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()) + } + } + }) + } + } +} diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 5eafb72..ed10378 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -433,6 +433,17 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.forgetWindowDocuments(originID) } invalidated := adapter.applyTreeInvalidation(eventType, event) + if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 && + adapter.applicationProcesses[originID.BusName] == "pidgin" { + // Read-only transcripts need not own native focus. Forward their + // retirement before the ordinary dead-object early return, without + // querying a destroyed accessible or broadening focus ownership. + retired := NewEvent(eventType, Snapshot{BusName: originID.BusName, Path: originID.Path, + ApplicationProcess: "pidgin", Defunct: true}) + retired.Detail1 = 1 + adapter.enqueueSecureEvent(retired, secureEpoch) + return + } if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 && adapter.webNotificationRoots[originID] { delete(adapter.webNotificationRoots, originID) // Retire a known toast before the normal dead-object early return. @@ -446,6 +457,17 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { // notification script needs their semantics before that early return, // including when the provider is outside foreground ownership. webNotification := adapter.webNotification(event) + if message := adapter.pidginMessage(event); message != nil { + // A transcript insertion is a passive announcement. Keep it out of + // native focus capture, selected-descendant resolution and review. + chatEvent := NewEvent(TextInsertedEvent, Snapshot{BusName: message.Provider, + Path: message.Conversation, Role: "text", ApplicationProcess: "pidgin"}) + chatEvent.ChatMessage = message + chatEvent.Window = NewObjectID(message.Provider, message.Window) + chatEvent.Foreground = adapter.Foreground() + adapter.enqueueSecureEvent(chatEvent, secureEpoch) + return + } if invalidated && webNotification == nil && eventType != ChildrenAddedEvent && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) { return } diff --git a/internal/atspi/model.go b/internal/atspi/model.go index 17135b0..e9bb629 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -23,6 +23,7 @@ import ( "unicode" "unicode/utf8" + "git.stormux.org/storm/cthulhu/internal/messaging" "git.stormux.org/storm/cthulhu/internal/webscripts" ) @@ -264,6 +265,9 @@ func (snapshot Snapshot) ID() ObjectID { } type Event struct { + // ChatMessage is copied by a verified application adapter before routine + // background filtering. It never transfers focus or retains a native object. + ChatMessage *messaging.Message // WebNotification is selected on the native owner before background-event // filtering. It contains copied content and no retained native references. WebNotification *webscripts.Notification diff --git a/internal/atspi/pidgin_native.go b/internal/atspi/pidgin_native.go new file mode 100644 index 0000000..89d47c0 --- /dev/null +++ b/internal/atspi/pidgin_native.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +/* +#include "adapter_native.h" +static gboolean pidgin_typing_attributes(AtspiText *text, gint offset, + gchar **scale, gchar **weight) { + gint start = 0, end = 0; + GError *error = NULL; + GHashTable *attributes = atspi_text_get_attribute_run(text, offset, FALSE, + &start, &end, &error); + gboolean known = attributes != NULL && error == NULL; + if (known) { + *scale = g_strdup(g_hash_table_lookup(attributes, "scale")); + *weight = g_strdup(g_hash_table_lookup(attributes, "weight")); + } + if (attributes != NULL) g_hash_table_unref(attributes); + if (error != NULL) g_clear_error(&error); + return known; +} +*/ +import "C" + +import ( + "strconv" + "time" + + "git.stormux.org/storm/cthulhu/internal/messaging" +) + +// pidginMessage follows legacy chat.py's transcript contract, never a window +// title or application-supplied name. Only verified Pidgin transcript insertions +// bypass background filtering. Entry typing and buddy-list changes do not. +func (adapter *Adapter) pidginMessage(event *C.AtspiEvent) *messaging.Message { + if canonicalTextChangeEventType(C.GoString(event._type)) != TextInsertedEvent || adapter.applicationProcess(event.source) != "pidgin" || + C.atspi_accessible_get_role(event.source, nil) != C.ATSPI_ROLE_TEXT { + return nil + } + states := C.atspi_accessible_get_state_set(event.source) + if states == nil { + return nil + } + transcript := C.atspi_state_set_contains(states, C.ATSPI_STATE_MULTI_LINE) != 0 && + C.atspi_state_set_contains(states, C.ATSPI_STATE_EDITABLE) == 0 && + C.atspi_state_set_contains(states, C.ATSPI_STATE_DEFUNCT) == 0 + C.g_object_unref(C.gpointer(states)) + if !transcript { + return nil + } + current := C.atspi_accessible_get_parent(event.source, nil) + if current == nil { + return nil + } + if C.atspi_accessible_get_role(current, nil) != C.ATSPI_ROLE_SCROLL_PANE { + C.g_object_unref(C.gpointer(current)) + return nil + } + id := nativeObjectID(event.source) + message := &messaging.Message{Provider: id.BusName, Conversation: id.Path} + deadline := time.Now().Add(50 * time.Millisecond) + for depth := 0; current != nil && depth < 32 && time.Now().Before(deadline); depth++ { + role := C.atspi_accessible_get_role(current, nil) + if role == C.ATSPI_ROLE_PAGE_TAB && message.RoomName == "" || role == C.ATSPI_ROLE_FRAME { + if message.RoomName == "" { + name := C.atspi_accessible_get_name(current, nil) + message.RoomName = sanitizeSnapshotField(C.GoString(name)) + C.g_free(C.gpointer(name)) + } + if role == C.ATSPI_ROLE_FRAME { + window := nativeObjectID(current) + if window.BusName == id.BusName { + message.Window = window.Path + } + C.g_object_unref(C.gpointer(current)) + current = nil + break + } + } + parent := C.atspi_accessible_get_parent(current, nil) + C.g_object_unref(C.gpointer(current)) + current = parent + } + if current != nil { + C.g_object_unref(C.gpointer(current)) + } + if !id.Valid() || message.Window == "" { + return nil + } + payload := C.cthulhu_event_payload(event) + message.Text = sanitizeTextContext(C.GoString(payload)) + C.g_free(C.gpointer(payload)) + // Pidgin inserts typing status into the transcript with smaller/lighter + // attributes. Legacy defaults suppress it and exclude it from history. + if text := C.atspi_accessible_get_text(event.source); text != nil { + // Scale is not part of the reader's ordinary spoken-attribute set. + // Query both Pidgin-specific markers without expanding generic capture. + var scaleValue, weightValue *C.gchar + known := C.pidgin_typing_attributes(text, event.detail1, &scaleValue, &weightValue) != 0 + scale, scaleErr := strconv.ParseFloat(C.GoString(scaleValue), 64) + weight, weightErr := strconv.Atoi(C.GoString(weightValue)) + C.g_free(C.gpointer(scaleValue)) + C.g_free(C.gpointer(weightValue)) + message.Typing = scaleErr == nil && scale < 1 || weightErr == nil && weight < 400 + C.g_object_unref(C.gpointer(text)) + if !known { + return nil + } // A destroyed range cannot establish a message. + } else { + return nil + } + return message +} diff --git a/internal/atspi/pidgin_native_test.go b/internal/atspi/pidgin_native_test.go new file mode 100644 index 0000000..fe8fbe4 --- /dev/null +++ b/internal/atspi/pidgin_native_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestNativePidginTranscriptCapture(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_MESSAGING") != "1" { + t.Skip("requires private Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + for _, processName := range []string{"pidgin", "messaging-test"} { + t.Run(processName, func(t *testing.T) { + command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_messaging.py", processName) + 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) + } + defer 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) + } + if _, err := adapter.CaptureDesktop(ctx); err != nil { + t.Fatal(err) + } + if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil { + t.Fatalf("focus: %s, %v", output, err) + } + fmt.Fprintln(input, "message\ntyping\ntyping-weight\nedit\nbarrier") + messages, typing := 0, 0 + var transcript ObjectID + retired, barrier := false, false + for { + select { + case event := <-adapter.Events(): + if event.Source.ApplicationProcess != processName { + continue + } + if event.ChatMessage != nil { + if processName != "pidgin" { + t.Fatal("unrelated process received chat metadata") + } + message := event.ChatMessage + transcript = event.Source.ID() + // GTK can publish an empty insertion during initial enumeration. + if message.Text == "" { + continue + } + if message.Provider != event.Source.BusName || message.Conversation != event.Source.Path || message.Window == "" || message.RoomName == "" { + t.Fatalf("incomplete identities: %+v", message) + } + if message.Typing { + typing++ + } else { + messages++ + if message.Text != "Username: Hello\n" { + t.Fatalf("message content = %q", message.Text) + } + } + } + if event.Type == DefunctStateEvent && event.Detail1 != 0 && transcript.Valid() && event.Source.ID() == transcript { + retired = true + } + if event.Type == TextInsertedEvent && event.Payload == "Messaging barrier" { + barrier = true + if processName == "pidgin" && (messages != 1 || typing != 2) { + t.Fatalf("captured %d messages, %d typing statuses", messages, typing) + } + if processName == "pidgin" { + fmt.Fprintln(input, "destroy") + } + } + if barrier && (processName != "pidgin" || retired) { + return + } + case <-ctx.Done(): + t.Fatalf("transcript/barrier missing: %v (messages=%d, typing=%d)", ctx.Err(), messages, typing) + } + } + }) + } +} diff --git a/internal/input/bindings.go b/internal/input/bindings.go index 26b923f..e1e3f2e 100644 --- a/internal/input/bindings.go +++ b/internal/input/bindings.go @@ -52,6 +52,13 @@ func bindingDefaults(layout KeyboardLayout, scope string) CommandBindings { } func CommandLabel(command Command) string { + if index, copyMessage, ok := MessageCommandIndex(command); ok { + action := "Review" + if copyMessage { + action = "Copy" + } + return fmt.Sprintf("%s chat message %d", action, index+1) + } if binding, ok := LookupDocumentList(command); ok { return "List " + strings.ToLower(binding.Title) } @@ -97,6 +104,19 @@ func ResolveCommandBindings(layout KeyboardLayout, browse bool, overrides []Bind if override.Layout != layout { continue } + // Chat defaults fill unassigned keys. An explicit assignment must + // keep working when the reader gains a new default shortcut. Do + // this before inserting any overrides so two explicit assignments + // still conflict, including overrides of the chat commands. + for _, key := range override.Keys { + chord, err := ParseCommandChord(key) + if err != nil { + return nil, err + } + if _, _, chatDefault := MessageCommandIndex(global[chord]); chatDefault { + delete(global, chord) + } + } bindings := global if override.Scope == "browse" { bindings = document @@ -175,6 +195,7 @@ func ValidateBindingOverrides(overrides []BindingOverride) error { func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRow { var rows []BindingRow + effective, _ := ResolveCommandBindings(layout, true, overrides) for _, scope := range []string{"global", "browse"} { groups := make(map[Command][]string) for chord, command := range bindingDefaults(layout, scope) { @@ -203,6 +224,15 @@ func BindingRows(layout KeyboardLayout, overrides []BindingOverride) []BindingRo break } } + if _, _, chat := MessageCommandIndex(command); chat { + keys = nil + for chord, bound := range effective { + if bound == command { + keys = append(keys, FormatCommandChord(chord)) + } + } + sort.Strings(keys) + } rows = append(rows, BindingRow{scope, command, CommandLabel(command), strings.Join(keys, "; "), defaults}) } } diff --git a/internal/input/commands.go b/internal/input/commands.go index 72c417e..76b83be 100644 --- a/internal/input/commands.go +++ b/internal/input/commands.go @@ -218,7 +218,6 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings { {Symbol: 'v', Modifiers: 1, CthulhuModifier: true}: PresentVersion, {Symbol: 'a', CthulhuModifier: true}: ToggleDocumentMode, {Symbol: 'h', CthulhuModifier: true}: ToggleLearnMode, - {Symbol: 0xffbe, CthulhuModifier: true}: ToggleLearnMode, {Symbol: 'h', Modifiers: 1, CthulhuModifier: true}: DocumentPreviousHeading, {Symbol: 'k', CthulhuModifier: true, Modifiers: controlModifier}: DocumentNextLink, {Symbol: 'k', Modifiers: controlModifier | 1, CthulhuModifier: true}: DocumentPreviousLink, @@ -236,6 +235,10 @@ func DefaultCommandBindings(layout KeyboardLayout) CommandBindings { {Symbol: leftKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectPreviousSibling, {Symbol: rightKeySymbol, Modifiers: controlModifier, CthulhuModifier: true}: ObjectNextSibling, } + for index := 0; index < 9; index++ { + bindings[CommandChord{Symbol: 0xffbe + uint32(index), CthulhuModifier: true}] = MessageCommand(index, false) + bindings[CommandChord{Symbol: 0xffbe + uint32(index), Modifiers: 1, CthulhuModifier: true}] = MessageCommand(index, true) + } if layout == DesktopKeyboardLayout { bindings[CommandChord{Symbol: 'o', CthulhuModifier: true}] = OCRWindow bindings[CommandChord{Symbol: 0xffab}] = ReadAll // KP_Add diff --git a/internal/input/messaging.go b/internal/input/messaging.go new file mode 100644 index 0000000..65ab957 --- /dev/null +++ b/internal/input/messaging.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "strings" + +// Message commands are independent of the physical key. Configured bindings, +// learn mode and both keyboard layouts use the same numbered requests. +func MessageCommand(index int, copyMessage bool) Command { + if index < 0 || index >= 9 { + return "" + } + action := "review" + if copyMessage { + action = "copy" + } + return Command(action + "-chat-message-" + string(rune('1'+index))) +} + +func MessageCommandIndex(command Command) (index int, copyMessage, ok bool) { + number, found := strings.CutPrefix(string(command), "review-chat-message-") + if !found { + number, found = strings.CutPrefix(string(command), "copy-chat-message-") + copyMessage = true + } + if !found || len(number) != 1 || number[0] < '1' || number[0] > '9' { + return 0, false, false + } + return int(number[0] - '1'), copyMessage, true +} diff --git a/internal/input/messaging_test.go b/internal/input/messaging_test.go new file mode 100644 index 0000000..bb25ff7 --- /dev/null +++ b/internal/input/messaging_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "testing" + +func TestMessagingBindingsAndExplicitAssignments(t *testing.T) { + for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} { + for _, browse := range []bool{false, true} { + bindings, err := ResolveCommandBindings(layout, browse, nil) + if err != nil { + t.Fatal(err) + } + if bindings[CommandChord{Symbol: 'h', CthulhuModifier: true}] != ToggleLearnMode { + t.Fatal("learn mode shortcut changed") + } + for index := 0; index < 9; index++ { + for _, copyMessage := range []bool{false, true} { + modifiers := uint16(0) + if copyMessage { + modifiers = 1 + } + command := bindings[CommandChord{Symbol: 0xffbe + uint32(index), Modifiers: modifiers, CthulhuModifier: true}] + gotIndex, gotCopy, ok := MessageCommandIndex(command) + if !ok || gotIndex != index || gotCopy != copyMessage { + t.Fatalf("chat chord = %s", command) + } + } + } + overrides := []BindingOverride{{Layout: layout, Scope: "global", Command: Preferences, Keys: []string{"Cthulhu+F8"}}} + custom, err := ResolveCommandBindings(layout, browse, overrides) + if err != nil || custom[CommandChord{Symbol: 0xffc5, CthulhuModifier: true}] != Preferences { + t.Fatalf("explicit assignment lost: %v", err) + } + for _, row := range BindingRows(layout, overrides) { + if row.Command == MessageCommand(7, false) && row.Keys != "" { + t.Fatal("shortcut list shows a displaced chat binding") + } + } + overrides = append(overrides, BindingOverride{Layout: layout, Scope: "global", Command: MessageCommand(0, false), Keys: []string{"Cthulhu+F8"}}) + if ValidateBindingOverrides(overrides) == nil { + t.Fatal("two explicit assignments were accepted") + } + } + } + for _, command := range []Command{"review-chat-message-0", "review-chat-message-10", "copy-chat-message-01", ToggleLearnMode} { + if _, _, ok := MessageCommandIndex(command); ok { + t.Fatalf("accepted %q", command) + } + } +} diff --git a/internal/input/ocr_test.go b/internal/input/ocr_test.go index 5783861..32aa4b8 100644 --- a/internal/input/ocr_test.go +++ b/internal/input/ocr_test.go @@ -49,6 +49,9 @@ func TestOCRBindingsPreserveGlobalAndLaptopReview(t *testing.T) { t.Fatal("OCR refresh or page navigation binding missing") } for chord, command := range global { + if chord == (CommandChord{Symbol: 0xffc2, CthulhuModifier: true}) { + continue // The temporary OCR document owns its refresh key. + } if chord.CthulhuModifier && bindings[chord] != command { t.Fatalf("global changed: %v", chord) } diff --git a/internal/messaging/history.go b/internal/messaging/history.go new file mode 100644 index 0000000..a0f401b --- /dev/null +++ b/internal/messaging/history.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package messaging owns bounded, session-only conversation history. Application +// adapters supply copied messages; the reader owns input, output and security. +package messaging + +import "strings" + +const HistorySize = 9 +const maxProviders = 32 + +// Message contains no native objects. Provider is the unique accessibility bus +// name, so restarting an application cannot expose its previous instance's chat. +type Message struct { + Provider, Conversation, Window, RoomName, Text string + Typing bool +} + +type providerHistory struct { + provider string + messages []Message +} + +// History is owned by the reader event loop. Focus changes retain it; window +// destruction retires that window's messages, and screen lock clears it all. +type History struct{ providers []providerHistory } + +func (history *History) Add(message Message) bool { + message.Text = strings.Trim(message.Text, "\n") + if message.Typing || strings.TrimSpace(message.Text) == "" || message.Provider == "" || message.Conversation == "" || message.Window == "" { + return false + } + characters := []rune(message.Text) + message.Text = string(characters[:min(len(characters), 4096)]) + index := -1 + for i := range history.providers { + if history.providers[i].provider == message.Provider { + index = i + break + } + } + if index < 0 { + if len(history.providers) == maxProviders { + history.providers = history.providers[1:] + } + history.providers = append(history.providers, providerHistory{provider: message.Provider}) + index = len(history.providers) - 1 + } + provider := &history.providers[index] + if len(provider.messages) == HistorySize { + provider.messages = provider.messages[1:] + } + provider.messages = append(provider.messages, message) + return true +} + +// Previous uses zero for the newest message, matching Cthulhu+F1. +func (history *History) Previous(provider string, index int) (Message, bool) { + if index < 0 || index >= HistorySize { + return Message{}, false + } + for _, entry := range history.providers { + if entry.provider == provider && index < len(entry.messages) { + return entry.messages[len(entry.messages)-1-index], true + } + } + return Message{}, false +} + +func (history *History) Retire(provider, path string) { + for i := len(history.providers) - 1; i >= 0; i-- { + entry := &history.providers[i] + if entry.provider != provider { + continue + } + messages := entry.messages[:0] + for _, message := range entry.messages { + if message.Window != path && message.Conversation != path { + messages = append(messages, message) + } + } + clear(entry.messages[len(messages):]) + entry.messages = messages + if len(messages) == 0 { + history.providers = append(history.providers[:i], history.providers[i+1:]...) + } + } +} + +func (history *History) Reset() { history.providers = nil } diff --git a/internal/messaging/history_test.go b/internal/messaging/history_test.go new file mode 100644 index 0000000..9b21eab --- /dev/null +++ b/internal/messaging/history_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package messaging + +import ( + "fmt" + "strings" + "testing" +) + +func TestHistoryOrdersConversationsAndIsolatesApplicationInstances(t *testing.T) { + var history History + for i := 0; i < 12; i++ { + history.Add(Message{Provider: ":1.42", Conversation: fmt.Sprintf("/room%d", i%2), Window: "/window", Text: fmt.Sprintf("Message %d", i)}) + } + history.Add(Message{Provider: ":1.43", Conversation: "/room0", Window: "/window", Text: "Other instance"}) + for i := 0; i < HistorySize; i++ { + message, ok := history.Previous(":1.42", i) + if !ok || message.Text != fmt.Sprintf("Message %d", 11-i) { + t.Fatalf("slot %d = %+v, %t", i, message, ok) + } + } + for _, index := range []int{-1, 9} { + if _, ok := history.Previous(":1.42", index); ok { + t.Fatal("out-of-range slot exists") + } + } + if _, ok := history.Previous(":1.44", 0); ok { + t.Fatal("restarted provider inherited old history") + } + // Identical messages from real insertions are legitimate separate entries. + for range 2 { + history.Add(Message{Provider: ":1.42", Conversation: "/room0", Window: "/window", Text: "Repeated"}) + } + for i := range 2 { + message, _ := history.Previous(":1.42", i) + if message.Text != "Repeated" { + t.Fatal("identical messages collapsed") + } + } +} + +func TestHistoryRetirementTypingAndBounds(t *testing.T) { + var history History + message := Message{Provider: ":1.42", Conversation: "/one", Window: "/window1", Text: "First"} + history.Add(message) + message.Conversation, message.Window, message.Text = "/two", "/window2", "Second" + history.Add(message) + message.Typing = true + if history.Add(message) { + t.Fatal("typing entered history") + } + history.Retire(":1.42", "/window1") + if got, _ := history.Previous(":1.42", 0); got.Text != "Second" { + t.Fatal("unrelated window was retired") + } + if _, ok := history.Previous(":1.42", 1); ok { + t.Fatal("destroyed window remains") + } + history.Retire(":1.42", "/two") + if _, ok := history.Previous(":1.42", 0); ok { + t.Fatal("destroyed transcript remains") + } + message.Typing = false + message.Text = "\n" + strings.Repeat("界", 5000) + "\n" + history.Add(message) + if got, _ := history.Previous(":1.42", 0); len([]rune(got.Text)) != 4096 { + t.Fatal("Unicode message is not bounded") + } + for i := 0; i < 64; i++ { + message.Provider = fmt.Sprintf(":1.%d", i+100) + history.Add(message) + } + if len(history.providers) != maxProviders { + t.Fatal("provider count is not bounded") + } + history.Reset() + if _, ok := history.Previous(message.Provider, 0); ok { + t.Fatal("lock retained messages") + } +} diff --git a/internal/platform/xorg/listener_native_test.go b/internal/platform/xorg/listener_native_test.go index f951ce5..6886bfc 100644 --- a/internal/platform/xorg/listener_native_test.go +++ b/internal/platform/xorg/listener_native_test.go @@ -162,7 +162,14 @@ func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) { } return input.CommandEvent{} } - for _, opener := range []string{"Insert+h", "Insert+F1"} { + key("Insert+F1") + want(input.MessageCommand(0, false)) + if listener.LearnModeActive() { + t.Fatal("message review entered learn mode") + } + key("Insert+Shift_L+F9") + want(input.MessageCommand(8, true)) + for _, opener := range []string{"Insert+h"} { key(opener) want(input.ToggleLearnMode) if !listener.LearnModeActive() || !listener.CommandGrabActive() { @@ -187,7 +194,7 @@ func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) { want(input.ToggleLearnMode) key("F2") want(input.LearnDefaultShortcuts) - key("Insert+F1") + key("Insert+h") want(input.ToggleLearnMode) key("F3") want(input.LearnApplicationShortcuts) @@ -197,13 +204,13 @@ func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) { want(input.ToggleLearnMode) key("Insert+h") want(input.ToggleLearnMode) - key("Insert+F1") + key("Insert+h") want(input.LearnKey) // The modifier itself is also named in learn mode. want(input.ToggleLearnMode) if listener.LearnModeActive() || listener.CommandGrabActive() { t.Fatal("exit did not release keyboard") } - key("Insert+F1") + key("Insert+h") want(input.ToggleLearnMode) key("Insert+h") want(input.LearnKey) @@ -238,7 +245,7 @@ func TestNativeLearnModeCapturesKeysAndRestoresCommands(t *testing.T) { t.Fatal("unlock did not restore command grabs") } } - key("Insert+F1") + key("Insert+h") if event := want(input.ToggleLearnMode); !event.LearnModeStarted { t.Fatal("learn mode did not restart after secure mode") } diff --git a/internal/presentation/typing_echo.go b/internal/presentation/typing_echo.go index f0cf38a..f55120a 100644 --- a/internal/presentation/typing_echo.go +++ b/internal/presentation/typing_echo.go @@ -50,11 +50,12 @@ func TypingEcho(event atspi.Event, previousText string, options Options) (string return characterEcho, kind } context := event.Source.TextBeforeCaret - if context == "" { - context = event.Source.Text - } if (event.Payload == "\n" || event.Payload == "\t") && strings.TrimSpace(context) == "" { + // After splitting a line, Text describes the following line, which + // may already contain text. Completion belongs to the prior caret. context = previousText + } else if context == "" && !event.Source.TextCaretKnown { + context = event.Source.Text } if options.EnableEchoBySentence { if sentence := completedSentence(context, event.Payload); sentence != "" { diff --git a/internal/presentation/typing_echo_matrix_test.go b/internal/presentation/typing_echo_matrix_test.go new file mode 100644 index 0000000..10be33e --- /dev/null +++ b/internal/presentation/typing_echo_matrix_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package presentation + +import ( + "fmt" + "testing" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func TestTypingEchoModeCombinations(t *testing.T) { + for mask := 0; mask < 8; mask++ { + for _, enabled := range []bool{false, true} { + t.Run(fmt.Sprintf("modes=%03b/enabled=%v", mask, enabled), func(t *testing.T) { + options := DefaultOptions() + options.EnableKeyEcho = enabled + options.EnableEchoByCharacter = mask&1 != 0 + options.EnableEchoByWord = mask&2 != 0 + options.EnableEchoBySentence = mask&4 != 0 + // With printable key categories disabled, each mode must still + // work independently. Sentence completion takes precedence. + options.EnableAlphabeticKeys = false + options.EnableNumericKeys = false + options.EnablePunctuationKeys = false + options.EnableSpace = false + for _, tc := range []struct { + payload, context, word, sentence string + }{ + {"é", "Café", "", ""}, + {"7", "7", "", ""}, + {"😀", "😀", "", ""}, + {" ", "Café ", "Café", ""}, + {",", "Café,", "Café", ""}, + {" ", "First. Second. ", "", "Second."}, + {"\n", "First. Last line\n", "line", "Last line"}, + {" ", "Café ", "", ""}, + } { + want, wantKind := "", "key" + if options.EnableEchoByCharacter { + wantKind = "character" + if tc.payload != "\n" { + want = tc.payload + if want == " " { + want = "space" + } + } + } + if options.EnableEchoByWord && tc.word != "" { + want, wantKind = tc.word, "word" + } + if options.EnableEchoBySentence && tc.sentence != "" { + want, wantKind = tc.sentence, "sentence" + } + if !enabled { + want = "" + } + event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: tc.payload, + Source: atspi.Snapshot{Role: "entry", Editable: true, TextBeforeCaret: tc.context}} + text, kind := TypingEcho(event, "", options) + if text != want || text != "" && kind != wantKind { + t.Errorf("payload=%q context=%q: (%q, %q), want (%q, %q)", tc.payload, tc.context, text, kind, want, wantKind) + } + } + }) + } + } +} + +func TestTypingEchoLineBreakBeforeExistingText(t *testing.T) { + for _, mode := range []string{"word", "sentence"} { + for _, tc := range []struct { + name, payload, previous, following, want string + }{ + {"split line", "\n", "Hello", "following text", "Hello"}, + {"split line with tab", "\t", "Hello", "\tfollowing text", "Hello"}, + {"start of line", "\n", "", "following text", ""}, + {"blank line", "\n", "", "", ""}, + {"unicode", "\n", "Café", "following text", "Café"}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + options := DefaultOptions() + options.EnableEchoByWord = mode == "word" + options.EnableEchoBySentence = mode == "sentence" + event := atspi.Event{Type: atspi.TextInsertedEvent, Payload: tc.payload, + Source: atspi.Snapshot{Role: "entry", Editable: true, TextCaretKnown: true, Text: tc.following}} + want := tc.want + if mode == "sentence" && tc.payload == "\t" { + want = "" // A tab only completes a sentence after punctuation. + } + if got := TypingEchoText(event, tc.previous, options); got != want { + t.Fatalf("echo = %q, want %q", got, want) + } + }) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 8feec91..02846ac 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.10.26" +const Version = "2026.10.27" const Channel = "testing" var Build = Version diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 588f3dd..dbe1168 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -23,7 +23,7 @@ web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed docu config-read-preview,src/cthulhu/backends/toml_backend.py,settings,Read existing Cthulhu TOML and merge the startup profile,Storm Dragon,internal/config,implemented,go test ./internal/config ./cmd/cthulhu -count=1,,,,,Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work. config-speech-preview,src/cthulhu/speechdispatcherfactory.py,speech,Apply supported active-profile speech settings,Storm Dragon,internal/config internal/speech internal/app,implemented,go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1,,,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.,,Applies enablement description punctuation capitalization and default voice settings. typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,implemented,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. Preferences offer disabled enabled and edit-fields-only radio choices; edit-fields-only uses native editable metadata and Wine editor focus while preserving configured echo voices. Automated focus and persistence checks pass; live speech and physical Braille acceptance for the new mode remain pending. -typing-nonprintable-boundary-echo,src/cthulhu/typing_echo_presenter.py,input,Apply non-printable key and word or sentence echo settings,Storm Dragon,internal/config internal/input internal/platform/xorg internal/atspi internal/presentation internal/app,implemented,go test ./internal/config ./internal/input ./internal/platform/xorg ./internal/atspi ./internal/presentation ./internal/app -count=1,Pending physical Xorg category chord and pass-through matrix,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,XRecord copies stable non-printable names from the active keymap and suppresses configured Cthulhu modifier chords. Word and sentence echo use bounded text before the caret and retain the prior line across a newline. Printable delivery remains AT-SPI based to avoid duplicate XRecord speech. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. +typing-nonprintable-boundary-echo,src/cthulhu/typing_echo_presenter.py,input,Apply non-printable key and word or sentence echo settings,Storm Dragon,internal/config internal/input internal/platform/xorg internal/atspi internal/presentation internal/app,implemented,go test ./internal/config ./internal/input ./internal/platform/xorg ./internal/atspi ./internal/presentation ./internal/app -count=1; python3 tests/check_speech.py; python3 tests/native_test_session.py env CTHULHU_NATIVE_TYPING_ECHO=1 go test ./internal/app -run '^TestNativeTypingEchoLineSplit$' -count=1,Pending physical Xorg category chord and pass-through matrix; private GTK TextView Enter checks pass for word/sentence completion in the middle and at the start of existing text on 2026-10-27,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,XRecord copies stable non-printable names from the active keymap and suppresses configured Cthulhu modifier chords. Word and sentence echo use bounded text before the caret and retain the prior line across a newline. Printable delivery remains AT-SPI based to avoid duplicate XRecord speech. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. Echo regressions cover all eight character/word/sentence mode combinations and all sixteen custom-voice selections with the custom master on/off; delivery records retain custom_echo. Enter completion preserves an empty known caret context and never substitutes following-line text. See docs/testing/key-echo.md; new live listening acceptance remains pending. Wine provides individual insertion labels without word/sentence completion context. keyboard-speech-interrupt,src/cthulhu/input_event.py,input,Interrupt speech on non-consuming Control or Shift press,Storm Dragon,internal/input internal/platform/xorg internal/app,in-progress,go test ./internal/input ./internal/platform/xorg ./internal/app -count=1,Maintainer accepted Control and Shift interruption without stolen or stuck keys in the Stage 1 physical Xorg checklist on 2026-09-11.,,Speech interruption was audible during the accepted physical Xorg checklist.,,XRecord observation and speech-worker wiring are implemented; broader X11 matrix remains pending. where-am-i-basic,src/cthulhu/where_am_i_presenter.py,command,Repeat basic information about the current focused accessible,Storm Dragon,internal/input internal/platform/xorg internal/app internal/presentation,in-progress,go test ./internal/input ./internal/platform/xorg ./internal/app -count=1; python3 tests/native_x11.py,Private X11 proof covers unmodified desktop KP_Enter consumption and balanced replay of desktop Cthulhu+Return; maintainer accepted the corrected physical desktop binding on Xorg on 2026-09-13,,Maintainer confirmed both the basic spoken result and corrected desktop binding on 2026-09-13,,Uses the latest accepted immutable focus or managed-descendant snapshot and the Python no-location message. keyboardLayout selects desktop KP_Enter or laptop Cthulhu+Return. Detailed context repeated-press behavior and selection details remain pending. owned-object-tree,src/cthulhu/ax_object.py,review,Own stable accessible identities and navigate a validated tree without retaining native objects,Storm Dragon,internal/atspi internal/review internal/app,in-progress,go test ./internal/atspi ./internal/review ./internal/app -count=1; python3 tests/native_atspi.py,Private Xvfb D-Bus and GTK automation captures the desktop invalidates a removed defunct object recaptures into the same owned tree and invalidates references on adapter shutdown.,,,,Identity combines the AT-SPI application bus name and object path. Complete-tree replacement preserves live references; subtree invalidation detaches removed nodes and makes old references stale. Parent child sibling malformed oversized concurrent and structural-event policy cases have unit coverage. The runtime worker follows the latest focus keeps capture off event and command loops recaptures stale state and presents semantic movement or Python boundary messages. Capture waits honor context and individual libatspi calls use explicit 800 ms ordinary and startup timeouts. GtkBox native addition delivery and recapture pass alongside the original ListBox defunct proof. Incomplete child lists trigger one worker recapture preserving live independent review position; failure changed-focus and repeated-invalidation tests pass. Physical dynamic-container recovery native ambiguous removal simplified navigation and text ranges remain pending. Component geometry is recorded separately. Object-navigation key acceptance is recorded separately. @@ -67,3 +67,4 @@ present-window-title,Orca src/orca/where_am_i_presenter.py;src/orca/where_am_i_p edit-field-relation-labels,src/cthulhu/generator.py:_generateLabel;src/cthulhu/script_utilities.py:displayedLabel,speech,Announce relation-only labels for unnamed edit fields,,internal/atspi internal/presentation,implemented,CTHULHU_NATIVE_EDIT_LABELS=1 python3 tests/native_atspi.py; go test ./... -count=1; go vet ./...,Private GTK/Xvfb verifies single and multiple labels plus existing names and password exclusion. Read-only live irctest capture verifies all three settings labels. Maintainer confirmed installed-reader label speech on physical Xorg on 2026-10-20.,,Formatted speech includes each label. Maintainer confirmed the installed reader speaks irctest settings field labels on 2026-10-20.,Physical Braille check pending.,Direct LABELLED_BY targets supply an empty text or entry or password-field name. Queries and output are bounded; explicit names are preserved. Human diff review remains required before submission. mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py,web,Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels,,internal/atspi internal/review internal/app,implemented,"go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py",Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. The original Thunderbird regression message was not separately identified.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required. 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." diff --git a/tests/check_speech.py b/tests/check_speech.py index 5fa1eba..314a216 100755 --- a/tests/check_speech.py +++ b/tests/check_speech.py @@ -19,6 +19,13 @@ requiredTests = { "TestAutomaticTitleStillStopsForExplicitInput", "TestTitleProtectionDoesNotChangeOrdinaryReading", "TestRepeatTrackerRecognizesNativeSequences", + "TestTypingEchoModeCombinations", + "TestTypingEchoVoiceCombinations", + "TestTypingEchoLineBreakBeforeExistingText", + "TestTypingEchoLineBreakKeepsPriorCaretContext", + "TestEchoSpeakerSwitchesBothVoicesTransactionally", + "TestSpeechWorkerKeyEchoCompletesBeforeCaretSpeech", + "TestSpeechWorkerKeyEchoWaitStillAllowsInputInterruption", } @@ -33,10 +40,10 @@ def run_checks(sourceRoot: Path) -> None: testEnv["CTHULHU_WINE_TITLE_DBUS"] = "1" command = [ "dbus-run-session", "--", "go", "test", "-json", "-race", "-count=1", - "-timeout=60s", "-run", "^Test.*(Title|Repeat|Interrupt)|^Test(SpeechWorker|Cancel|WineReview)", - "./internal/app", "./internal/input", "./internal/speech", "./internal/wine", + "-timeout=60s", "-run", "^Test.*(Title|Repeat|Interrupt|Echo)|^Test(SpeechWorker|Cancel|WineReview)", + "./internal/app", "./internal/input", "./internal/presentation", "./internal/speech", "./internal/wine", ] - print("Checking speech queues, Wine metadata and key interruption...", flush=True) + print("Checking speech queues, key echo, Wine metadata and key interruption...", flush=True) result = run_private_session(command, cwd=sourceRoot, env=testEnv, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False) passedTests = set() diff --git a/tests/gtk_messaging.py b/tests/gtk_messaging.py new file mode 100644 index 0000000..1cef5e9 --- /dev/null +++ b/tests/gtk_messaging.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Private transcript provider matching legacy Pidgin's accessible hierarchy.""" +import ctypes +import os +import sys + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Atk", "1.0") +gi.require_version("GdkX11", "3.0") +from gi.repository import Atk, GdkX11, GLib, Gtk + +if ctypes.CDLL(None).prctl(15, sys.argv[1].encode(), 0, 0, 0) != 0: + raise RuntimeError("Cannot set fixture process identity") + +chatWindow = Gtk.Window(title="Conversation fixture") +notebook = Gtk.Notebook() +transcript = Gtk.TextView(editable=False) +scroll = Gtk.ScrolledWindow() +scroll.add(transcript) +notebook.append_page(scroll, Gtk.Label(label="Conversation")) +chatWindow.add(notebook) +chatWindow.show_all() +buffer = transcript.get_buffer() +typingTag = buffer.create_tag("typing", scale=0.8) +weightTag = buffer.create_tag("typing-weight", weight=300) + +foreground = Gtk.Window(title="Foreground fixture") +box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) +editor = Gtk.Entry() +marker = Gtk.Label(label="Marker") +marker.get_accessible().set_role(Atk.Role.ALERT) +box.add(editor) +box.add(marker) +foreground.add(box) +foreground.show_all() +pendingInput = "" + + +def receive_command(source, condition): + global pendingInput + content = os.read(sys.stdin.fileno(), 4096) + if not content: + Gtk.main_quit() + return False + pendingInput += content.decode() + while "\n" in pendingInput: + command, pendingInput = pendingInput.split("\n", 1) + if command == "message": + buffer.insert(buffer.get_end_iter(), "Username: Hello\n") + elif command == "typing": + buffer.insert_with_tags(buffer.get_end_iter(), "Username is typing\n", typingTag) + elif command == "typing-weight": + buffer.insert_with_tags(buffer.get_end_iter(), "Username is typing\n", weightTag) + elif command == "edit": + editor.set_text("Typed input") + elif command == "barrier": + marker.set_text("Messaging barrier") + elif command == "destroy": + transcript.destroy() + elif command == "quit": + Gtk.main_quit() + return False + return True + + +GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, receive_command) +GLib.idle_add(lambda: print("ready", GdkX11.X11Window.get_xid(foreground.get_window()), flush=True) or False) +Gtk.main() diff --git a/tests/gtk_typing_echo.py b/tests/gtk_typing_echo.py new file mode 100644 index 0000000..c67a4a2 --- /dev/null +++ b/tests/gtk_typing_echo.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Multiline editor with existing text on both sides of the insertion point.""" +import sys + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, Gtk + + +window = Gtk.Window(title="Typing echo probe") +window.set_default_size(480, 200) +window.connect("destroy", Gtk.main_quit) +editor = Gtk.TextView() +editor.set_accepts_tab(False) +editor.get_accessible().set_name("Echo editor") +buffer = editor.get_buffer() +buffer.set_text("Hello following text") +buffer.place_cursor(buffer.get_iter_at_offset(int(sys.argv[1]))) +window.add(editor) +window.show_all() +editor.grab_focus() + + +def ready(): + print("READY", flush=True) + return False + + +GLib.idle_add(ready) +Gtk.main()