diff --git a/docs/testing/messaging.md b/docs/testing/messaging.md index 8a1545a..5de24b0 100644 --- a/docs/testing/messaging.md +++ b/docs/testing/messaging.md @@ -14,6 +14,12 @@ occupy history slots. Identical messages remain separate entries. Log messages can speak in the background without taking focus. Mumble's message entry and other text controls do not supply history. +Mumble's server list can expose the same accessible label in every column, +including Ping and Users. Full-row reading announces that label once when all +columns repeat it. Rows containing distinct information retain their complete +column text, including equal ping and user-count values. Other applications' +tables retain repeated values. + The legacy controls for separate conversation histories, room-name prefixes, typing announcements, and message verbosity are not yet ported. @@ -148,6 +154,17 @@ Actual Pidgin listening, physical shortcuts, and Braille acceptance remain pendi ### Mumble live acceptance +For server-list row reading, open Server Connect and move between two saved +servers with Up/Down. Each server label should occur once per row announcement. +Try a public listing too: any distinct ping or user-count text exposed by Mumble +should remain present. Check channel/user navigation after connecting and a +multi-column table in another application; equal values in separate columns +must remain available there. + +Live listening confirmed that the repeated labels in two saved-server listings +are spoken once after this fix. Public listings and physical Braille remain +unverified for this change. + 1. Quit the current reader with **Cthulhu+Q**. Press **Ctrl+Escape**, then press **Shift+G**. These are separate key presses. The launch script rebuilds the reader and Braille helper, starts debug logging, and opens a test terminal. diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 6e5d739..a3ece54 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -318,6 +318,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A snapshot.TablePositionKnown = rowIndex >= 0 && columnIndex >= 0 snapshot.TableRowText = sanitizeSnapshotField(C.GoString(rowText)) snapshot.GUITable = guiTable != 0 + snapshot.TableRowText = mumbleTableRowText(snapshot) C.g_free(C.gpointer(tableName)) C.g_free(C.gpointer(tablePath)) C.g_free(C.gpointer(rowText)) diff --git a/internal/atspi/mumble.go b/internal/atspi/mumble.go index 6381a31..8fdc429 100644 --- a/internal/atspi/mumble.go +++ b/internal/atspi/mumble.go @@ -11,6 +11,19 @@ import ( var mumbleTimestamp = regexp.MustCompile(`^\[\d{2}:\d{2}:\d{2}\]\s*`) +// Mumble supplies the same accessible label for every server-list column, +// including Ping and Users. Collapse only a whole row of identical labels; +// ordinary equal-valued columns and rows containing other data retain them. +func mumbleTableRowText(snapshot Snapshot) string { + if snapshot.ApplicationProcess == "mumble" && snapshot.GUITable && + snapshot.Role == "table cell" && snapshot.Name != "" && + snapshot.TableColumnCount > 1 && snapshot.TableColumnCount <= 32 && + snapshot.TableRowText == snapshot.Name+strings.Repeat(", "+snapshot.Name, snapshot.TableColumnCount-1) { + return snapshot.Name + } + return snapshot.TableRowText +} + // Match legacy Mumble's inserted-message cleanup. Qt can insert the timestamp // separately, and uses paragraph separators and noncharacters in its log. func mumbleMessageText(text string) string { diff --git a/internal/atspi/mumble_native_test.go b/internal/atspi/mumble_native_test.go index 2a0f42e..3b5d408 100644 --- a/internal/atspi/mumble_native_test.go +++ b/internal/atspi/mumble_native_test.go @@ -86,3 +86,73 @@ func TestNativeMumbleTranscriptCapture(t *testing.T) { }) } } + +func TestNativeMumbleTableRows(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_MUMBLE") != "1" { + t.Skip("requires private Qt/Xorg/AT-SPI session") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + for _, process := range []string{"mumble", "table-test"} { + t.Run(process, func(t *testing.T) { + command := exec.CommandContext(ctx, "python3", "../../tests/qt_mumble_table.py", process) + 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() { 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 out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil { + t.Fatalf("focus: %s, %v", out, err) + } + for rowIndex, label := range []string{"Server Alpha", "Server Beta", "Server Gamma, 界"} { + key := "Down" + if rowIndex == 0 { + key = "Home" + } + if out, err := exec.CommandContext(ctx, "xdotool", "key", key).CombinedOutput(); err != nil { + t.Fatalf("select row: %s, %v", out, err) + } + selected: + for { + select { + case <-ctx.Done(): + t.Fatalf("row %d (%s) missing: %v", rowIndex, label, ctx.Err()) + case event := <-adapter.Events(): + cell := event.Source + if cell.ApplicationProcess != process || cell.Name != label || cell.TableColumnCount != 3 { + continue + } + want := strings.Join([]string{label, label, label}, ", ") + if process == "mumble" { + want = label + } + if rowIndex == 1 { + want = label + ", 12, 12" + } + if cell.TableRowText != want || !cell.GUITable || cell.TableRowIndex != rowIndex { + t.Fatalf("row %d context = %+v, want %q", rowIndex, cell, want) + } + break selected + } + } + } + }) + } +} diff --git a/internal/atspi/mumble_test.go b/internal/atspi/mumble_test.go index f9014be..78f84ce 100644 --- a/internal/atspi/mumble_test.go +++ b/internal/atspi/mumble_test.go @@ -6,6 +6,31 @@ package atspi import "testing" +func TestMumbleTableRowText(t *testing.T) { + for _, test := range []struct { + name, process, label, row, want string + columns int + gui bool + }{ + {"server label", "mumble", "Server Example", "Server Example, Server Example, Server Example", "Server Example", 3, true}, + {"commas and Unicode", "mumble", "Server Example, 界", "Server Example, 界, Server Example, 界, Server Example, 界", "Server Example, 界", 3, true}, + {"useful equal values", "mumble", "Server Example", "Server Example, 12, 12", "Server Example, 12, 12", 3, true}, + {"mixed labels", "mumble", "Server Example", "Server Example, 12, Server Example", "Server Example, 12, Server Example", 3, true}, + {"unrelated provider", "other", "Same", "Same, Same, Same", "Same, Same, Same", 3, true}, + {"document table", "mumble", "Same", "Same, Same, Same", "Same, Same, Same", 3, false}, + {"single column", "mumble", "Server Example", "Server Example", "Server Example", 1, true}, + {"empty cells", "mumble", "", "", "", 3, true}, + } { + t.Run(test.name, func(t *testing.T) { + snapshot := Snapshot{ApplicationProcess: test.process, Role: "table cell", Name: test.label, + TableRowText: test.row, TableColumnCount: test.columns, GUITable: test.gui} + if got := mumbleTableRowText(snapshot); got != test.want { + t.Fatalf("row = %q, want %q", got, test.want) + } + }) + } +} + func TestMumbleMessageText(t *testing.T) { for _, test := range []struct{ input, want string }{ {"[12:34:56]", ""}, diff --git a/internal/version/version.go b/internal/version/version.go index 570c111..3bacdd4 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.11.06" +const Version = "2026.11.07" const Channel = "testing" var Build = Version diff --git a/tests/native_messaging.py b/tests/native_messaging.py index dedd247..a653069 100644 --- a/tests/native_messaging.py +++ b/tests/native_messaging.py @@ -16,6 +16,7 @@ environment = dict(os.environ, GOCACHE="/tmp/cthulhu-go-cache", GOPROXY="off", QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1", NO_AT_BRIDGE="0") checks = [ ("./internal/atspi", "TestNativeMumbleTranscriptCapture"), + ("./internal/atspi", "TestNativeMumbleTableRows"), ("./internal/atspi", "TestNativePidginTranscriptCapture"), ("./internal/app", "TestNativeMessagingCopyPreservesClipboardText"), ("./internal/platform/xorg", "TestNativeLearnModeCapturesKeysAndRestoresCommands"), diff --git a/tests/qt_mumble_table.py b/tests/qt_mumble_table.py new file mode 100644 index 0000000..ff9682b --- /dev/null +++ b/tests/qt_mumble_table.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Private Qt table reproducing Mumble's repeated accessible column labels.""" +import ctypes +import sys + +from PySide6 import QtCore, QtWidgets + +if ctypes.CDLL(None).prctl(15, sys.argv[1].encode(), 0, 0, 0) != 0: + raise RuntimeError("Cannot set fixture process identity") + +application = QtWidgets.QApplication([]) +window = QtWidgets.QMainWindow() +window.setWindowTitle("Server table fixture") +table = QtWidgets.QTreeWidget() +table.setAccessibleName("Server list") +table.setHeaderLabels(["Servername", "Ping", "Users"]) +for labels in [ + ["Server Alpha"] * 3, + ["Server Beta", "12", "12"], + ["Server Gamma, 界"] * 3, +]: + item = QtWidgets.QTreeWidgetItem([labels[0], "12", "12"]) + for columnIndex, label in enumerate(labels): + item.setData(columnIndex, QtCore.Qt.ItemDataRole.AccessibleTextRole, label) + table.addTopLevelItem(item) +window.setCentralWidget(table) +window.resize(500, 250) +window.show() +table.setFocus() +QtCore.QTimer.singleShot(0, lambda: print("ready", int(window.winId()), flush=True)) +application.exec()