Avoid repeating Mumble server labels across columns

Collapse identical full-row labels only for Mumble while preserving mixed column data and repeated values from other providers. Add Qt native coverage and document live saved-server listening confirmation.
This commit is contained in:
Storm Dragon committed 2026-11-07 20:12:25 -05:00
1 parent 64440fbb78
commit 3dbd7f2554
8 files changed
+162 -1

No files matched your search

+17
View File
@@ -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.
@@ -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))
+13
View File
@@ -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 {
+70
View File
@@ -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
}
}
}
})
}
}
+25
View File
@@ -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]", ""},
+1 -1
View File
@@ -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
+1
View File
@@ -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"),
+34
View File
@@ -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()