Version bump and some lable/relation fixes.

This commit is contained in:
Storm Dragon committed 2026-10-18 22:11:54 -04:00
1 parent d717b93849
commit 396efbd7c5
15 files changed
+474 -8

No files matched your search

+8
View File
@@ -227,6 +227,14 @@ Stale native placement gets one refresh and retry at the same target and offset.
Braille routing also recognizes the temporary caret produced by clearing a Braille routing also recognizes the temporary caret produced by clearing a
selection, so it cannot reset the routed reading position. selection, so it cannot reset the routed reading position.
Reading a final ellipsis avoids the provider's native line-boundary request:
Chromium can calculate a boundary past truncated text and abort the browser.
At that character, Cthulhu reads a bounded logical line using explicit line
breaks; visual wrapping may be combined. Ordinary line, character and word
navigation retain their existing boundaries. The regression checks are
`go test ./internal/atspi -run TestTextBoundaryAvoidsProviderEllipsisAbort`
and `CTHULHU_NATIVE_BROWSER_TESTS=lines python3 tests/native_browser.py`.
Browse-mode arrows and Read All include text inside embedded editors. Reading Browse-mode arrows and Read All include text inside embedded editors. Reading
continues through the editor's paragraphs before reaching the following page continues through the editor's paragraphs before reaching the following page
content. content.
+32
View File
@@ -0,0 +1,32 @@
# Native text-control labels
Unnamed text controls can expose their labels through AT-SPI `LABELLED_BY`
relationships. Cthulhu uses those labels for text, entry and password-text
snapshots when the control has no accessible name. An existing name takes
precedence. The lookup follows only explicit targets, with a maximum of 16
targets and 4096 bytes; it does not guess from neighboring widgets or use
the field's value as its label.
Run the native GTK and presentation regression in a private display and bus:
```sh
python3 tests/native_test_session.py env CTHULHU_NATIVE_TEXT_LABELS=1 go test ./internal/presentation -run '^TestNativeTextLabelRelations$' -count=1 -timeout=40s -v
go test ./internal/atspi ./internal/presentation ./internal/braille
env CTHULHU_SPEECH_METADATA=1 python3 tests/native_atspi.py
```
The native regression covers empty and populated entries with mnemonic labels,
an explicit accessible name differing from its visible label, a password field,
an unlabeled entry beside unrelated labels, and multiple relationship targets.
It checks the captured names and resulting focus speech text. It does not
establish audible speech or physical Braille acceptance.
For live verification with a rebuilt reader, open I38's Create Desktop Shortcut
dialog. Tab and Shift+Tab through Target and Shortcut name while empty, then
populate both fields and repeat. Each focus announcement should include its
label and current value. Confirm editing and caret navigation still read field
contents normally. Check an explicitly named search field in the application
picker, plus ordinary labeled entries and password fields in another GTK
application. Password contents must remain private. If using Braille, confirm
labels appear and routing still reaches the editable text. Review and test
these changes before submission.
+49 -3
View File
@@ -8,6 +8,46 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot); static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot);
static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining); static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining);
// Some native entries leave Name empty and expose their label only through
// LABELLED_BY. Follow explicit relationships, never nearby text or field values.
static gchar *cthulhu_text_control_label(AtspiAccessible *source) {
GArray *relations = atspi_accessible_get_relation_set(source, NULL);
if (relations == NULL) return NULL;
GString *result = g_string_new("");
guint remaining = 16;
for (guint i = 0; i < relations->len && remaining > 0; i++) {
AtspiRelation *relation = g_array_index(relations, AtspiRelation *, i);
if (atspi_relation_get_relation_type(relation) != ATSPI_RELATION_LABELLED_BY) continue;
gint targets = atspi_relation_get_n_targets(relation);
for (gint j = 0; j < targets && remaining > 0; j++, remaining--) {
AtspiAccessible *label = atspi_relation_get_target(relation, j);
if (label == NULL) continue;
gchar *name = atspi_accessible_get_name(label, NULL);
if ((name == NULL || *name == '\0') &&
atspi_accessible_get_role(label, NULL) == ATSPI_ROLE_LABEL) {
AtspiText *text = atspi_accessible_get_text(label);
if (text != NULL) {
gint count = atspi_text_get_character_count(text, NULL);
g_free(name);
name = count > 0 ? atspi_text_get_text(text, 0, MIN(count, 1024), NULL) : NULL;
g_object_unref(text);
}
}
if (name != NULL && *g_strstrip(name) != '\0' &&
result->len + strlen(name) + 1 <= 4096) {
if (result->len > 0) g_string_append_c(result, ' ');
g_string_append(result, name);
}
g_free(name);
g_object_unref(label);
}
}
for (guint i = 0; i < relations->len; i++)
g_object_unref(g_array_index(relations, AtspiRelation *, i));
g_array_unref(relations);
return g_string_free(result, FALSE);
}
// Collect only the selected row. Walking an entire table on every arrow key // Collect only the selected row. Walking an entire table on every arrow key
// would block input in large application tables. // would block input in large application tables.
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
@@ -389,6 +429,12 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
g_error_free(error); g_error_free(error);
error = NULL; error = NULL;
} }
if (!snapshot.defunct && (snapshot.name == NULL || *snapshot.name == '\0') &&
(g_strcmp0(snapshot.role, "text") == 0 || g_strcmp0(snapshot.role, "entry") == 0 ||
g_strcmp0(snapshot.role, "password text") == 0)) {
g_free(snapshot.name);
snapshot.name = cthulhu_text_control_label(source);
}
cthulhu_capture_menu_checkability(source, &snapshot); cthulhu_capture_menu_checkability(source, &snapshot);
if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 || if (!snapshot.defunct && (g_strcmp0(snapshot.role, "list") == 0 ||
g_strcmp0(snapshot.role, "list box") == 0)) { g_strcmp0(snapshot.role, "list box") == 0)) {
@@ -556,7 +602,7 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
g_object_unref(text); g_object_unref(text);
return NULL; return NULL;
} }
AtspiTextRange *range = atspi_text_get_string_at_offset( AtspiTextRange *range = cthulhu_text_get_string_at_offset(
text, caretOffset, ATSPI_TEXT_GRANULARITY_LINE, &error); text, caretOffset, ATSPI_TEXT_GRANULARITY_LINE, &error);
// AXText.get_line_at_offset retries Chromium's invalid end-of-text // AXText.get_line_at_offset retries Chromium's invalid end-of-text
// range. A valid empty range is a real blank line and must stay empty. // range. A valid empty range is a real blank line and must stay empty.
@@ -568,7 +614,7 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
} }
if (retry) { if (retry) {
g_boxed_free(atspi_text_range_get_type(), range); g_boxed_free(atspi_text_range_get_type(), range);
range = atspi_text_get_string_at_offset( range = cthulhu_text_get_string_at_offset(
text, caretOffset - 1, ATSPI_TEXT_GRANULARITY_LINE, &error); text, caretOffset - 1, ATSPI_TEXT_GRANULARITY_LINE, &error);
} }
} }
@@ -836,7 +882,7 @@ gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity
g_object_unref(text); g_object_unref(text);
return NULL; return NULL;
} }
AtspiTextRange *range = atspi_text_get_string_at_offset( AtspiTextRange *range = cthulhu_text_get_string_at_offset(
text, caretOffset, unit, &error); text, caretOffset, unit, &error);
g_object_unref(text); g_object_unref(text);
if (error != NULL) { if (error != NULL) {
+2
View File
@@ -101,6 +101,8 @@ void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attribute
gboolean cthulhu_has_set_position(const gchar *role); gboolean cthulhu_has_set_position(const gchar *role);
gchar *cthulhu_live_content(AtspiAccessible *source); gchar *cthulhu_live_content(AtspiAccessible *source);
gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit); gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit);
AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error);
guint start_foreground_timer(gpointer data); guint start_foreground_timer(gpointer data);
const gchar *cthulhu_event_type(AtspiEvent *event); const gchar *cthulhu_event_type(AtspiEvent *event);
gint cthulhu_event_detail1(AtspiEvent *event); gint cthulhu_event_detail1(AtspiEvent *event);
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
#include <string.h>
AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error) {
if (unit != ATSPI_TEXT_GRANULARITY_LINE || offset < 0) {
return atspi_text_get_string_at_offset(text, offset, unit, error);
}
// Chromium can retain layout boundaries past truncated hypertext. In a
// captured crash, LINE at the final U+2026 (140 of 141 characters) tried
// to take a substring starting at 146 and aborted the entire browser.
// Checking returned boundaries is too late: use bounded GetText for an
// ellipsis before invoking the provider's boundary calculation.
GError *probeError = NULL;
guint character = atspi_text_get_character_at_offset(text, offset, &probeError);
if (probeError != NULL) {
g_propagate_error(error, probeError);
return NULL;
}
if (character != 0x2026) {
return atspi_text_get_string_at_offset(text, offset, unit, error);
}
gint count = atspi_text_get_character_count(text, &probeError);
if (probeError != NULL) {
g_propagate_error(error, probeError);
return NULL;
}
if (count <= 0 || offset != count - 1) {
return atspi_text_get_string_at_offset(text, offset, unit, error);
}
gint start = MAX(0, offset - 4095);
gchar *content = atspi_text_get_text(text, start, offset + 1, error);
if (content == NULL) return NULL;
if (!g_utf8_validate(content, -1, NULL) ||
g_utf8_strlen(content, -1) != offset + 1 - start ||
!g_str_has_suffix(content, "\342\200\246")) {
g_free(content);
return NULL;
}
// Preserve explicit line breaks. A provider's visual wrapping is unsafe
// at this character; retain the bounded logical line and native offsets.
gchar *line = strrchr(content, '\n');
line = line != NULL ? line + 1 : content;
AtspiTextRange *range = g_new0(AtspiTextRange, 1);
range->start_offset = start + g_utf8_pointer_to_offset(content, line);
range->end_offset = offset + 1;
range->content = g_strdup(line);
g_free(content);
return range;
}
@@ -62,4 +62,33 @@ func TestNativeBrowserLogicalLines(t *testing.T) {
t.Fatalf("preserved %s line = %+v, %v", test.name, line, err) t.Fatalf("preserved %s line = %+v, %v", test.name, line, err)
} }
} }
for _, name := range []string{"Truncated preview…", "猫🐈…", "…"} {
ref := findNamedReference(t, tree, name)
if !ref.Valid() {
t.Fatalf("ellipsis object %q missing", name)
}
offset := len([]rune(name)) - 1
for _, unit := range []DocumentCaretUnit{DocumentCaretLine, DocumentCaretBrailleLine, DocumentCaretLineRemainder} {
line, err := adapter.ReadDocumentCaret(ctx, ref, offset, unit)
if err != nil || line.End != offset+1 || !strings.HasSuffix(line.Text, "…") {
t.Fatalf("ellipsis %q unit %d = %+v, %v", name, unit, line, err)
}
}
if err := adapter.SetDocumentCaretPosition(ctx, ref, focus, offset); err != nil {
t.Fatalf("place caret on ellipsis: %v", err)
}
// Drain the actual native caret event, exercising snapshot text,
// character/word capture and logical-line repair before proceeding.
for {
select {
case event := <-adapter.Events():
if event.Type == CaretEvent && strings.HasSuffix(event.Source.BrailleLine, "…") {
goto caretCaptured
}
case <-ctx.Done():
t.Fatal("ellipsis caret event missing: ", ctx.Err())
}
}
caretCaptured:
}
} }
+1 -1
View File
@@ -160,7 +160,7 @@ func nativePointText(ctx context.Context, source, window *C.AtspiAccessible, x,
} }
read := func(granularity C.AtspiTextGranularity) (string, int) { read := func(granularity C.AtspiTextGranularity) (string, int) {
var err *C.GError var err *C.GError
value := C.atspi_text_get_string_at_offset(text, offset, granularity, &err) value := C.cthulhu_text_get_string_at_offset(text, offset, granularity, &err)
if err != nil { if err != nil {
C.g_error_free(err) C.g_error_free(err)
} }
+1 -1
View File
@@ -62,7 +62,7 @@ func (adapter *Adapter) nativeTransferItem(ctx context.Context, object ObjectID,
offset = 0 offset = 0
} }
} }
line := C.atspi_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil) line := C.cthulhu_text_get_string_at_offset(text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, nil)
if line == nil { if line == nil {
return TransferItem{}, errors.New("focused line unavailable") return TransferItem{}, errors.New("focused line unavailable")
} }
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
#include <string.h>
static const gchar *content;
static guint boundaryCalls, characterCalls, textCalls;
static gboolean failProbe, changedText;
guint atspi_text_get_character_at_offset(AtspiText *text, gint offset, GError **error) {
characterCalls++;
if (failProbe) {
g_set_error_literal(error, g_quark_from_static_string("probe"), 1, "removed text");
return 0;
}
return offset >= 0 && offset < g_utf8_strlen(content, -1)
? g_utf8_get_char(g_utf8_offset_to_pointer(content, offset)) : 0;
}
gint atspi_text_get_character_count(AtspiText *text, GError **error) {
return g_utf8_strlen(content, -1);
}
gchar *atspi_text_get_text(AtspiText *text, gint start, gint end, GError **error) {
textCalls++;
if (changedText) return g_strdup("");
g_assert_cmpint(start, >=, 0);
g_assert_cmpint(end - start, <=, 4096);
g_assert_cmpint(end, <=, g_utf8_strlen(content, -1));
const gchar *first = g_utf8_offset_to_pointer(content, start);
return g_strndup(first, g_utf8_offset_to_pointer(content, end) - first);
}
AtspiTextRange *atspi_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error) {
boundaryCalls++;
// Model the verified browser failure: asking for LINE at the final
// ellipsis aborts inside the provider, before it can return a bad range.
if (unit == ATSPI_TEXT_GRANULARITY_LINE && offset >= 0 &&
offset == g_utf8_strlen(content, -1) - 1) {
g_assert_cmpuint(g_utf8_get_char(g_utf8_offset_to_pointer(content, offset)), !=, 0x2026);
}
AtspiTextRange *range = g_new0(AtspiTextRange, 1);
range->end_offset = g_utf8_strlen(content, -1);
range->content = g_strdup(content);
return range;
}
static void assert_range(const gchar *value, gint offset, AtspiTextGranularity unit,
gint start, gint end, const gchar *expected, guint calls) {
content = value;
boundaryCalls = characterCalls = textCalls = 0;
GError *error = NULL;
AtspiTextRange *range = cthulhu_text_get_string_at_offset(NULL, offset, unit, &error);
g_assert_no_error(error);
g_assert_nonnull(range);
g_assert_cmpint(range->start_offset, ==, start);
g_assert_cmpint(range->end_offset, ==, end);
g_assert_cmpstr(range->content, ==, expected);
g_assert_cmpuint(boundaryCalls, ==, calls);
if (unit != ATSPI_TEXT_GRANULARITY_LINE || offset < 0) g_assert_cmpuint(characterCalls, ==, 0);
g_assert_cmpuint(textCalls, ==, calls == 0 ? 1 : 0);
g_free(range->content);
g_free(range);
}
int main(void) {
assert_range("Truncated text…", 14, ATSPI_TEXT_GRANULARITY_LINE, 0, 15, "Truncated text…", 0);
assert_range("…", 0, ATSPI_TEXT_GRANULARITY_LINE, 0, 1, "…", 0);
assert_range("First\n猫🐈…", 8, ATSPI_TEXT_GRANULARITY_LINE, 6, 9, "猫🐈…", 0);
assert_range("Ordinary text", 12, ATSPI_TEXT_GRANULARITY_LINE, 0, 13, "Ordinary text", 1);
assert_range("An… inside", 2, ATSPI_TEXT_GRANULARITY_LINE, 0, 10, "An… inside", 1);
assert_range("Text…", 4, ATSPI_TEXT_GRANULARITY_CHAR, 0, 5, "Text…", 1);
assert_range("Text…", 4, ATSPI_TEXT_GRANULARITY_WORD, 0, 5, "Text…", 1);
assert_range("Text…", -1, ATSPI_TEXT_GRANULARITY_LINE, 0, 5, "Text…", 1);
gchar *prefix = g_strnfill(5000, 'x');
gchar *longText = g_strconcat(prefix, "…", NULL);
assert_range(longText, 5000, ATSPI_TEXT_GRANULARITY_LINE, 905, 5001, longText + 905, 0);
g_free(prefix);
g_free(longText);
content = "Changed…";
boundaryCalls = 0;
changedText = TRUE;
GError *error = NULL;
g_assert_null(cthulhu_text_get_string_at_offset(NULL, 7, ATSPI_TEXT_GRANULARITY_LINE, &error));
g_assert_no_error(error);
g_assert_cmpuint(boundaryCalls, ==, 0);
changedText = FALSE;
failProbe = TRUE;
g_assert_null(cthulhu_text_get_string_at_offset(NULL, 7, ATSPI_TEXT_GRANULARITY_LINE, &error));
g_assert_nonnull(error);
g_assert_cmpuint(boundaryCalls, ==, 0);
g_clear_error(&error);
return 0;
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestTextBoundaryAvoidsProviderEllipsisAbort(t *testing.T) {
flags, err := exec.Command("pkg-config", "--cflags", "--libs", "atspi-2").CombinedOutput()
if err != nil {
t.Fatalf("native text dependencies: %v: %s", err, flags)
}
binary := filepath.Join(t.TempDir(), "text-boundary")
args := []string{"-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-I.",
"adapter_text_boundary.c", "testdata/text_boundary.c", "-o", binary}
args = append(args, strings.Fields(string(flags))...)
if output, err := exec.Command("cc", args...).CombinedOutput(); err != nil {
t.Fatalf("compile native boundary regression: %v: %s", err, output)
}
if output, err := exec.Command(binary).CombinedOutput(); err != nil {
t.Fatalf("native boundary regression: %v: %s", err, output)
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ package atspi
/* /*
#cgo pkg-config: atspi-2 #cgo pkg-config: atspi-2
#include <atspi/atspi.h> #include "adapter_native.h"
#include <string.h> #include <string.h>
// Bound the Go copy even when an application returns an oversized line. // Bound the Go copy even when an application returns an oversized line.
static int review_text_length(const gchar *text, int limit) { static int review_text_length(const gchar *text, int limit) {
@@ -176,7 +176,7 @@ func (source nativeReviewText) line(ctx context.Context, offset int) (TextRange,
return TextRange{}, err return TextRange{}, err
} }
var nativeErr *C.GError var nativeErr *C.GError
line := C.atspi_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr) line := C.cthulhu_text_get_string_at_offset(source.text, C.gint(offset), C.ATSPI_TEXT_GRANULARITY_LINE, &nativeErr)
if line != nil { if line != nil {
defer C.g_boxed_free(C.atspi_text_range_get_type(), C.gpointer(line)) defer C.g_boxed_free(C.atspi_text_range_get_type(), C.gpointer(line))
} }
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestNativeTextLabelRelations(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TEXT_LABELS") != "1" {
t.Skip("requires private GTK/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../tests/text_label_fixture.py")
fixture.Env = append(os.Environ(), "NO_AT_BRIDGE=0", "GTK_MODULES=gail:atk-bridge")
stdin, err := fixture.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := fixture.StdoutPipe()
if err != nil {
t.Fatal(err)
}
fixture.Stderr = os.Stderr
if err := fixture.Start(); err != nil {
t.Fatal(err)
}
defer func() { stdin.Close(); fixture.Wait() }()
if scanner := bufio.NewScanner(stdout); !scanner.Scan() || scanner.Text() != "ready" {
t.Fatal("fixture did not start")
}
var previous atspi.ObjectID
for index, probe := range []struct{ name, value string }{
{"Destination:", ""},
{"Display name:", "Example document"},
{"Preferred name", "Example value"},
{"Secret:", ""},
{"", "Unlabeled value"},
{"Account identifier", ""},
} {
if _, err := fmt.Fprintln(stdin, index); err != nil {
t.Fatal(err)
}
waitFocus:
for {
select {
case <-ctx.Done():
t.Fatal("missing entry focus", index)
case event := <-adapter.Events():
if (event.Type != atspi.FocusEvent && event.Type != atspi.FocusedStateEvent) || !event.Source.Focused {
continue
}
snapshot := event.Source
if snapshot.Role != "text" && snapshot.Role != "password text" {
continue
}
if snapshot.ID() == previous {
continue
}
previous = snapshot.ID()
if snapshot.Name != probe.name {
t.Errorf("entry %d name = %q, want %q", index, snapshot.Name, probe.name)
}
speech := FocusText(snapshot)
for _, part := range []string{probe.name, probe.value} {
if part != "" && !strings.Contains(speech, part) {
t.Errorf("entry %d speech %q omits %q", index, speech, part)
}
}
if strings.Contains(speech, "private fixture value") {
t.Error("password content leaked into speech")
}
break waitFocus
}
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import "strings"
const Name = "Cthulhu" const Name = "Cthulhu"
const Version = "2026.10.17" const Version = "2026.10.18"
const Channel = "master" const Channel = "master"
var Build = Version var Build = Version
+2
View File
@@ -11,5 +11,7 @@ Second</pre>
<code style="display:block; width:1px">Code text</code> <code style="display:block; width:1px">Code text</code>
<textarea aria-label="Editable lines">First <textarea aria-label="Editable lines">First
Second</textarea> Second</textarea>
<div aria-label="Truncated preview…">Truncated preview<span>…</span></div>
<p>First line<br>猫🐈…</p>
</body> </body>
</html> </html>
+69
View File
@@ -0,0 +1,69 @@
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Native label relationships for private Xvfb/AT-SPI tests."""
import subprocess
import sys
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Atk", "1.0")
gi.require_version("GdkX11", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Atk, Gdk, GdkX11, GLib, Gtk
def main():
window = Gtk.Window(title="Text label probe")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
window.add(box)
entries = []
for text, value, explicitName, protected in (
("_Destination:", "", "", False),
("Display _name:", "Example document", "", False),
("Visible _label:", "Example value", "Preferred name", False),
("_Secret:", "private fixture value", "", True),
(None, "Unlabeled value", "", False),
):
entry = Gtk.Entry()
entry.set_text(value)
entry.set_visibility(not protected)
if explicitName:
entry.get_accessible().set_name(explicitName)
if text:
label = Gtk.Label.new_with_mnemonic(text)
label.set_mnemonic_widget(entry)
box.pack_start(label, False, False, 0)
box.pack_start(entry, False, False, 0)
entries.append(entry)
# Explicit multi-label relations also preserve their provider order.
combined = Gtk.Entry()
for text in ("Account", "identifier"):
label = Gtk.Label(label=text)
box.pack_start(label, False, False, 0)
combined.get_accessible().add_relationship(Atk.RelationType.LABELLED_BY, label.get_accessible())
box.pack_start(combined, False, False, 0)
entries.append(combined)
window.show_all()
Gdk.Display.get_default().sync()
subprocess.run(["xdotool", "windowfocus", str(GdkX11.X11Window.get_xid(window.get_window()))], check=True)
def command_ready(_source, condition):
if condition & GLib.IO_HUP:
Gtk.main_quit()
return False
line = sys.stdin.readline()
if not line:
Gtk.main_quit()
return False
entries[int(line)].grab_focus()
return True
GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command_ready)
GLib.idle_add(lambda: (print("ready", flush=True), False)[1])
Gtk.main()
if __name__ == "__main__":
main()