Group inline links and list items into browse lines
This commit is contained in:
1 parent
3dbd7f2554
commit
d01965c00d
16 files changed
+309
-15
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# Browse visual lines
|
||||
|
||||
Cthulhu previously made every link and list item a line boundary, even when
|
||||
several appeared on the same rendered row. The local Orca reference
|
||||
`src/orca/scripts/web/script_utilities.py:_get_line_contents_at_offset` uses
|
||||
geometry plus block ancestry. Its `ax_utilities.py:get_nearest_block_ancestor`
|
||||
treats links and non-grid list items as inline for this purpose.
|
||||
|
||||
Cthulhu now lets ordinary links inherit their block and list items inherit
|
||||
their list. Native text rectangles still determine whether fragments share
|
||||
a line. Headings, paragraphs, lists, table cells, non-span sections, hard
|
||||
newlines, code and editable controls retain their boundaries. Grid list
|
||||
items stay independent using the captured AT-SPI display attribute. Explicitly
|
||||
named atomic links and other whole controls retain their existing stops.
|
||||
This is a focused improvement, not full parity with Orca's layout policy.
|
||||
|
||||
The move owns temporary assembled lines; the retained cursor, activation and
|
||||
braille routing still use actual objects and native offsets. Say All retains
|
||||
fragments and their privacy ancestry. Speech coalesces plain spans but keeps
|
||||
inline link roles, visited states and role sounds, plus heading announcements.
|
||||
|
||||
## Automated checks
|
||||
|
||||
- Review, AT-SPI, presentation and application package tests.
|
||||
- `CTHULHU_NATIVE_BROWSER_TESTS=fragments,lists,namedlinks,linkedheadings,document,selection python3 tests/native_browser.py`
|
||||
- `python3 tests/native_firefox.py`
|
||||
|
||||
The fragments fixture covers adjacent links, text around a link, a horizontal
|
||||
list, vertical list items, separate headings, reverse navigation, wrapped
|
||||
spans, code, Say All and native braille routing. Firefox's existing baseline
|
||||
covers browser navigation and tabs; the new horizontal fixture is tested in
|
||||
Brave. Tests use private Xvfb/D-Bus sessions without replacing the live reader.
|
||||
|
||||
## Live acceptance
|
||||
|
||||
1. Quit the current reader with Cthulhu+Q.
|
||||
2. Press Ctrl+Escape, then Shift+G. These are separate key presses. The second
|
||||
rebuilds and launches Cthulhu through `~/devel/cthulhu-go.sh`.
|
||||
3. Open `file:///home/angus/devel/cthulhu/tests/browser-fragments.html` in the
|
||||
browser. In browse mode, press Ctrl+Home, then 3 to reach “Line grouping”.
|
||||
4. Press Down once: hear First link, Second link and trailing text together,
|
||||
with link announcements according to the configured speech/sound settings.
|
||||
Down again reads East and West together. Up returns to the full link row.
|
||||
5. Continue Down: North and South remain separate, followed by Separate
|
||||
heading and Final paragraph as separate lines. The browser must stay in
|
||||
browse mode and keyboard focus must not jump into an unrelated control.
|
||||
6. Use K/Shift+K to select individual links and Enter to activate one. Each
|
||||
link still targets Split heading. Check Home/End and Left/Right within a
|
||||
combined row, then Down to leave that whole row.
|
||||
7. With braille, confirm the row contains both links; route to characters in
|
||||
each link and check the caret and subsequent activation target. Run Say
|
||||
All and listen for missing/repeated content and retained link roles.
|
||||
8. Repeat on the original page in Brave and Firefox. Report exact speech,
|
||||
braille content, focused control and keys whose behavior differs. Inspect
|
||||
`~/devel/cthulhu-go.log` if anything is unexpected.
|
||||
|
||||
Physical keyboard, audible speech and physical braille acceptance remain
|
||||
pending. Human review and live testing are required before submission.
|
||||
@@ -284,6 +284,9 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
|
||||
ancestor.Name = ""
|
||||
line.Contents = append(line.Contents, atspi.DocumentContent{Snapshot: ancestor})
|
||||
}
|
||||
if result.DocumentCaretLine != nil && result.DocumentCaretCommand != review.CaretNextWord && result.DocumentCaretCommand != review.CaretPreviousWord {
|
||||
line = presentation.DocumentVisualLineForSpeech(*result.DocumentCaretLine)
|
||||
}
|
||||
spoken := presentation.DocumentLineSpeechWithOptions(line, options)
|
||||
request.Text, request.Chunks = spoken.Text, spoken.Chunks
|
||||
request.Sound = presentation.DocumentLineSoundCues(line, options)
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
"git.stormux.org/storm/cthulhu/internal/sound"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -420,3 +421,30 @@ func TestCaretPresentationUsesAncestorRoleSound(t *testing.T) {
|
||||
t.Fatal("missing link sound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaretVisualLinePresentsEveryLink(t *testing.T) {
|
||||
plain := atspi.Snapshot{BusName: ":1.20", Path: "/plain", Role: "static"}
|
||||
link := atspi.Snapshot{BusName: ":1.20", Path: "/link", Role: "link", Visited: true}
|
||||
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{
|
||||
{Snapshot: plain, Text: "Before "}, {Snapshot: link, Text: "Example"}, {Snapshot: plain, Text: " after."},
|
||||
}, SourceContexts: [][]atspi.Snapshot{{plain}, {link}, {plain}}}
|
||||
result := review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine,
|
||||
DocumentCaretText: "Before Example after.", DocumentCaretLine: &line,
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: plain},
|
||||
}
|
||||
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
|
||||
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions())
|
||||
if got := (<-queue.requests).Text; got != "Before, Example, visited link, after." {
|
||||
t.Fatalf("line speech = %q", got)
|
||||
}
|
||||
result.ReadAllContext, result.ReadAllLines = t.Context(), []atspi.DocumentLine{line}
|
||||
presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions())
|
||||
request := <-queue.requests
|
||||
var text []string
|
||||
for _, part := range request.ReadAll {
|
||||
text = append(text, part.Text)
|
||||
}
|
||||
if got := strings.Join(text, " "); got != "Before Example visited link after." {
|
||||
t.Fatalf("Say All = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
|
||||
options.Reading = &reading
|
||||
options.ReadFullRowInGUITable = false
|
||||
for index, line := range result.ReadAllLines {
|
||||
if len(line.Contents) > 1 && len(line.SourceContexts) == len(line.Contents) {
|
||||
line = presentation.DocumentVisualLineForSpeech(line)
|
||||
}
|
||||
if spoken := presentation.DocumentLineSpeechWithOptions(line, options); spoken.Text != "" {
|
||||
// One request owns the original observation ticket. Conservatively
|
||||
// keep the entire reading private if any spoken line is sensitive
|
||||
|
||||
@@ -575,6 +575,7 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
|
||||
if (xmlRoles != NULL) {
|
||||
snapshot.xmlRoles = g_strdup(xmlRoles);
|
||||
}
|
||||
snapshot.display = g_strdup(g_hash_table_lookup(attributes, "display"));
|
||||
const gchar *htmlTag = g_hash_table_lookup(attributes, "tag");
|
||||
if (htmlTag != NULL) snapshot.htmlTag = g_strdup(htmlTag);
|
||||
const gchar *elementID = g_hash_table_lookup(attributes, "id");
|
||||
|
||||
@@ -53,6 +53,7 @@ typedef struct {
|
||||
gchar *valueText;
|
||||
gchar *xmlRoles;
|
||||
gchar *htmlTag;
|
||||
gchar *display;
|
||||
gchar *elementID;
|
||||
gboolean supportsClick;
|
||||
gboolean liveRegion;
|
||||
|
||||
@@ -162,6 +162,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
|
||||
snapshot.CharacterCount = characterCount
|
||||
snapshot.EmbeddedObjectCount = int(captured.embeddedObjectCount)
|
||||
snapshot.XMLRoles = sanitizeSnapshotField(C.GoString(captured.xmlRoles))
|
||||
snapshot.Display = sanitizeSnapshotField(C.GoString(captured.display))
|
||||
snapshot.HTMLTag = sanitizeSnapshotField(C.GoString(captured.htmlTag))
|
||||
snapshot.ElementID = sanitizeSnapshotField(C.GoString(captured.elementID))
|
||||
snapshot.CurrentValue = float64(captured.currentValue)
|
||||
@@ -181,6 +182,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
|
||||
C.g_free(C.gpointer(captured.valueText))
|
||||
C.g_free(C.gpointer(captured.xmlRoles))
|
||||
C.g_free(C.gpointer(captured.htmlTag))
|
||||
C.g_free(C.gpointer(captured.display))
|
||||
C.g_free(C.gpointer(captured.elementID))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ type Snapshot struct {
|
||||
ToolkitName string
|
||||
XMLRoles string
|
||||
HTMLTag string
|
||||
Display string // CSS display attribute used for document line boundaries.
|
||||
ElementID string
|
||||
|
||||
// PositionInSet is one-based; zero means unavailable. SetSize must be
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package presentation
|
||||
|
||||
import "git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
|
||||
// DocumentVisualLineForSpeech coalesces native fragments without losing inline
|
||||
// link roles. The returned copy is for speech only: braille and activation keep
|
||||
// the original objects and offsets. SourceContexts retains the privacy evidence.
|
||||
func DocumentVisualLineForSpeech(line atspi.DocumentLine) atspi.DocumentLine {
|
||||
result := line
|
||||
result.Contents = nil
|
||||
var previous atspi.ObjectID
|
||||
var heading *atspi.Snapshot
|
||||
for i, content := range line.Contents {
|
||||
snapshot := content.Snapshot
|
||||
var semantic atspi.ObjectID
|
||||
if snapshot.Role == "heading" && heading == nil {
|
||||
copy := snapshot
|
||||
heading = ©
|
||||
}
|
||||
ancestors := snapshot.BrailleContext
|
||||
if i < len(line.SourceContexts) {
|
||||
ancestors = line.SourceContexts[i]
|
||||
}
|
||||
if snapshot.Role == "link" {
|
||||
semantic = snapshot.ID()
|
||||
}
|
||||
for _, ancestor := range ancestors {
|
||||
if ancestor.Role == "heading" && heading == nil {
|
||||
copy := ancestor
|
||||
heading = ©
|
||||
}
|
||||
if ancestor.Role == "link" && !semantic.Valid() {
|
||||
snapshot, semantic = ancestor, ancestor.ID()
|
||||
}
|
||||
}
|
||||
value := content.RawText
|
||||
if value == "" {
|
||||
value = content.Text
|
||||
}
|
||||
if len(result.Contents) > 0 && semantic == previous {
|
||||
last := &result.Contents[len(result.Contents)-1]
|
||||
last.Text += content.Separator + value
|
||||
last.RawText = last.Text
|
||||
last.Snapshot.Misspelled = last.Snapshot.Misspelled || content.Snapshot.Misspelled
|
||||
} else {
|
||||
if !semantic.Valid() {
|
||||
// Plain spans share a text run; their container is not a role cue.
|
||||
snapshot.Role = "static"
|
||||
}
|
||||
snapshot.Misspelled = content.Snapshot.Misspelled
|
||||
result.Contents = append(result.Contents, atspi.DocumentContent{Snapshot: snapshot, Text: value, RawText: value})
|
||||
}
|
||||
previous = semantic
|
||||
}
|
||||
if heading != nil {
|
||||
heading.Name, heading.Text = "", ""
|
||||
result.Contents = append(result.Contents, atspi.DocumentContent{Snapshot: *heading})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package presentation
|
||||
|
||||
import (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVisualLineSpeechRetainsInlineLinkRoles(t *testing.T) {
|
||||
plain := atspi.Snapshot{BusName: ":1.20", Path: "/plain", Role: "static"}
|
||||
link := atspi.Snapshot{BusName: ":1.20", Path: "/link", Role: "link", Visited: true}
|
||||
other := atspi.Snapshot{BusName: ":1.20", Path: "/other", Role: "link"}
|
||||
line := atspi.DocumentLine{
|
||||
Contents: []atspi.DocumentContent{
|
||||
{Snapshot: plain, Text: "Before "},
|
||||
{Snapshot: plain, Text: "split"}, {Snapshot: plain, Text: " link", RawText: " link"},
|
||||
{Snapshot: other, Text: "Next", Separator: " "},
|
||||
{Snapshot: plain, Text: " after"}, {Snapshot: plain, Text: "."},
|
||||
},
|
||||
SourceContexts: [][]atspi.Snapshot{{plain}, {plain, link}, {plain, link}, {other}, {plain}, {plain}},
|
||||
}
|
||||
spoken := DocumentVisualLineForSpeech(line)
|
||||
if got := DocumentLineText(spoken); got != "Before, split link, visited link, Next, link, after." {
|
||||
t.Fatalf("speech = %q", got)
|
||||
}
|
||||
if len(line.Contents) != 6 || line.Contents[1].Snapshot.Role != "static" {
|
||||
t.Fatal("speech conversion changed routing sources")
|
||||
}
|
||||
if len(spoken.SourceContexts) != 6 {
|
||||
t.Fatal("lost privacy sources")
|
||||
}
|
||||
}
|
||||
@@ -116,17 +116,23 @@ func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atsp
|
||||
}
|
||||
|
||||
// Retain semantic boundaries even if unrelated blocks have matching y values.
|
||||
// Inline spans/emphasis do not introduce boundaries. Links remain independent
|
||||
// stops with their existing activation, voice, state and sound presentation.
|
||||
// Links and same-row list items inherit their surrounding block. Headings,
|
||||
// paragraphs and table cells remain separate; grid items retain their boundaries.
|
||||
func visual_line_owners(nodes []atspi.DocumentNode) map[int]atspi.ObjectID {
|
||||
owners := make(map[int]atspi.ObjectID, len(nodes))
|
||||
byID := make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))
|
||||
displays := make(map[atspi.ObjectID]string, len(nodes))
|
||||
for index, node := range nodes {
|
||||
owner := byID[node.Parent]
|
||||
snapshot := node.Snapshot
|
||||
displays[node.Reference.ID()] = snapshot.Display
|
||||
switch snapshot.Role {
|
||||
case "paragraph", "heading", "list item", "table cell", "row header", "column header", "link", "document web", "document frame", "embedded":
|
||||
case "paragraph", "heading", "list", "table cell", "row header", "column header", "document web", "document frame", "embedded":
|
||||
owner = node.Reference.ID()
|
||||
case "list item":
|
||||
if strings.Contains(displays[node.Parent], "grid") {
|
||||
owner = node.Reference.ID()
|
||||
}
|
||||
case "section":
|
||||
if snapshot.HTMLTag != "span" {
|
||||
owner = node.Reference.ID()
|
||||
|
||||
@@ -80,9 +80,7 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
|
||||
{CaretLineEnd, "."},
|
||||
{CaretNextLine, "Four five six."},
|
||||
{CaretNextLine, "Next paragraph."},
|
||||
{CaretNextLine, "Before"},
|
||||
{CaretNextLine, "ordinary link"},
|
||||
{CaretNextLine, "after."},
|
||||
{CaretNextLine, "Before ordinary link after."},
|
||||
{CaretNextLine, "Split heading"},
|
||||
{CaretNextLine, "Code first"},
|
||||
{CaretNextLine, ""}, // Preserve the provider's separate preformatted newline.
|
||||
@@ -150,7 +148,7 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
|
||||
t.Fatalf("Read All: %+v", result)
|
||||
}
|
||||
for index, want := range []string{"One two three.", "Four five six.", "Next paragraph."} {
|
||||
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
|
||||
if got := visual_line_test_text(result.ReadAllLines[index]); got != want {
|
||||
t.Fatalf("Read All line %d: %q, want %q", index, got, want)
|
||||
}
|
||||
}
|
||||
@@ -211,4 +209,27 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
|
||||
movement.Position.Snapshot.Role, movement.Position.Snapshot.Path, movement.Position.Snapshot.CharacterCount, movement.Moved, err, step.want)
|
||||
}
|
||||
}
|
||||
if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move, err := cursor.Move(ctx, DocumentHeading, 3, false); err != nil || !move.Moved {
|
||||
t.Fatalf("line grouping heading: %+v, %v", move, err)
|
||||
}
|
||||
for _, step := range []struct {
|
||||
command DocumentCaretCommand
|
||||
want string
|
||||
}{
|
||||
{CaretNextLine, "First link Second link trailing text."},
|
||||
{CaretNextLine, "East West"},
|
||||
{CaretPreviousLine, "First link Second link trailing text."},
|
||||
{CaretNextLine, "East West"},
|
||||
{CaretNextLine, "• North"}, {CaretNextLine, "• South"},
|
||||
{CaretNextLine, "Separate heading"}, {CaretNextLine, "Final paragraph."},
|
||||
} {
|
||||
movement, err := cursor.MoveCaret(ctx, adapter, step.command)
|
||||
if err != nil || strings.TrimSpace(movement.Text) != step.want {
|
||||
t.Fatalf("grouping %s: %q, %v; want %q", step.command, movement.Text, err, step.want)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -163,16 +163,16 @@ func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
|
||||
t.Fatalf("read all: %+v", result)
|
||||
}
|
||||
for index, want := range []string{"ne two.", "Three four.", "Next paragraph"} {
|
||||
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
|
||||
if got := visual_line_test_text(result.ReadAllLines[index]); got != want {
|
||||
t.Fatalf("line %d: %q, want %q", index, got, want)
|
||||
}
|
||||
}
|
||||
// Say All flattens the fragments into one text value, but must retain
|
||||
// Say All retains the fragments and must retain
|
||||
// every original source and ancestry for Remote's privacy decision.
|
||||
contexts := result.ReadAllLines[0].SourceContexts
|
||||
if len(contexts) != 2 || len(contexts[0]) < 2 || len(contexts[1]) < 2 ||
|
||||
contexts[0][0].ID() != webID("/one") || contexts[1][0].ID() != webID("/two") || contexts[1][1].ID() != webID("/span") {
|
||||
t.Fatal("flattened Say All line lost fragment ancestry")
|
||||
t.Fatal("Say All line lost fragment ancestry")
|
||||
}
|
||||
if cursor.places[0].offset != 1 {
|
||||
t.Fatal("assembly moved the user's cursor")
|
||||
@@ -290,3 +290,65 @@ func TestDocumentFragmentLineAcrossWrappedObject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func visual_line_test_text(line atspi.DocumentLine) string {
|
||||
var text strings.Builder
|
||||
for _, part := range line.Contents {
|
||||
text.WriteString(part.Separator)
|
||||
text.WriteString(part.Text)
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func TestDocumentSameRowLinksAndListItems(t *testing.T) {
|
||||
for _, role := range []string{"link", "list item"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
cursor, reader := fragment_cursor(t, "list")
|
||||
doc, _ := cursor.tree.Document(t.Context(), cursor.places[0].object)
|
||||
var nodes []atspi.TreeNode
|
||||
for _, node := range doc.Nodes {
|
||||
if node.Reference.ID() == webID("/one") || node.Reference.ID() == webID("/two") || node.Reference.ID() == webID("/three") || node.Reference.ID() == webID("/four") {
|
||||
node.Snapshot.Role = role
|
||||
}
|
||||
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children})
|
||||
}
|
||||
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, step := range []struct {
|
||||
command DocumentCaretCommand
|
||||
want string
|
||||
}{
|
||||
{CaretDocumentStart, "One two."}, {CaretNextLine, "Three four."},
|
||||
{CaretPreviousLine, "One two."}, {CaretLineEnd, "."}, {CaretNextLine, "Three four."},
|
||||
} {
|
||||
move, err := cursor.MoveCaret(t.Context(), reader, step.command)
|
||||
if err != nil || move.Text != step.want {
|
||||
t.Fatalf("%s: %q, %v; want %q", step.command, move.Text, err, step.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentGridListItemsStaySeparate(t *testing.T) {
|
||||
cursor, reader := fragment_cursor(t, "list")
|
||||
doc, _ := cursor.tree.Document(t.Context(), cursor.places[0].object)
|
||||
var nodes []atspi.TreeNode
|
||||
for _, node := range doc.Nodes {
|
||||
if node.Snapshot.Role == "list" {
|
||||
node.Snapshot.Display = "grid"
|
||||
}
|
||||
if node.Reference.ID() == webID("/one") {
|
||||
node.Snapshot.Role = "list item"
|
||||
}
|
||||
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children})
|
||||
}
|
||||
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move, err := cursor.MoveCaret(t.Context(), reader, CaretDocumentStart)
|
||||
if err != nil || strings.TrimSpace(move.Text) != "One" {
|
||||
t.Fatalf("grid boundary: %+v, %v", move, err)
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,7 @@ func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Resul
|
||||
movement.Position.Snapshot.Misspelled = movement.Misspelled
|
||||
line := atspi.DocumentLine{Document: movement.Position.Document, Contents: []atspi.DocumentContent{{Snapshot: movement.Position.Snapshot, Text: movement.Text, RawText: movement.RawText}}}
|
||||
if movement.Line != nil {
|
||||
// Presentation deliberately flattens a visual line into one spoken
|
||||
// string. Keep every contributing source for independent privacy
|
||||
// checks, including sources outside the caret object's ancestry.
|
||||
line.SourceContexts = movement.Line.SourceContexts
|
||||
line = *movement.Line
|
||||
}
|
||||
owner, err := cursor.tree.DocumentContext(movement.Position.Object)
|
||||
if err != nil {
|
||||
@@ -136,7 +133,12 @@ func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Resul
|
||||
} else {
|
||||
line.Context = owner.Ancestors
|
||||
roles := map[string]bool{movement.Position.Snapshot.Role: true}
|
||||
for _, ancestor := range owner.Ancestors {
|
||||
ancestors := owner.Ancestors
|
||||
if movement.Line != nil {
|
||||
// Assembled lines carry each fragment's semantic ancestry.
|
||||
ancestors = nil
|
||||
}
|
||||
for _, ancestor := range ancestors {
|
||||
if (ancestor.Role == "link" || ancestor.Role == "heading") && !roles[ancestor.Role] {
|
||||
roles[ancestor.Role] = true
|
||||
ancestor.Name = ""
|
||||
|
||||
Vendored
+1
@@ -76,3 +76,4 @@ atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca
|
||||
web-message-article-focus,src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py,web,Announce named message articles when focus enters their controls,,internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go,in-progress,Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md.,Private Chromium fixture uses isolated X11 and D-Bus.,,Recorded speech only; live Discord listening pending.,Physical display acceptance pending.,Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit.
|
||||
web-disclosure-refresh,src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py,web,Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation,,internal/atspi internal/app,implemented,"CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py",Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile.,,Original-page audible speech acceptance pending.,Physical Braille acceptance pending.,Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
|
||||
mumble-message-history,src/cthulhu/scripts/apps/Mumble/chat.py;src/cthulhu/chat.py,messaging,Review or copy twelve recent Mumble Activity log messages with Cthulhu+F1 through F12,,internal/atspi internal/messaging internal/app internal/input,in-progress,Message cleanup and twelve-slot ordering tests; focused messaging race checks; python3 -B tests/native_messaging.py.,Private Qt transcript and unrelated-provider checks; synthetic X11 review and copy chords through F12; clipboard checks.,,Recorded presentation tested; actual Mumble listening pending.,Single flash and review text tested; physical display pending.,Legacy combined history defaults. Strip timestamps and Qt noncharacters; ignore timestamp-only insertions; preserve repeated messages and keyboard focus. Pidgin keeps its nine-message review range. History is session-only and clears on lock or transcript retirement. See docs/testing/messaging.md for live acceptance.
|
||||
web-inline-visual-lines,Orca src/orca/scripts/web/script_utilities.py;src/orca/ax_utilities.py,web,Group same-row ordinary links and list items during browse line navigation,,internal/review internal/presentation internal/app internal/atspi,in-progress,Review AT-SPI presentation and app package tests; private browser fragments lists namedlinks linkedheadings document selection; Firefox baseline and tabs,Private Brave and Firefox Xvfb sessions,,Speech requests verified; live listening pending,Native fragment routing checked; physical display acceptance pending,See docs/testing/browser-visual-lines.md. Preserve block boundaries and grid items; atomic named controls remain independent. Human review and original-page live acceptance pending.
|
||||
|
@@ -27,5 +27,11 @@
|
||||
<p>Review the code.</p><p>Report actionable issues.</p>
|
||||
</div>
|
||||
<p>After the suggestion.</p>
|
||||
<h3>Line grouping</h3>
|
||||
<p><a href="#after">First link</a> <a href="#after">Second link</a> trailing text.</p>
|
||||
<ul style="display:flex; gap:24px; list-style:none; padding:0"><li>East</li><li>West</li></ul>
|
||||
<ul><li>North</li><li>South</li></ul>
|
||||
<h3>Separate heading</h3>
|
||||
<p>Final paragraph.</p>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user