Group inline links and list items into browse lines

This commit is contained in:
ak47 committed 2026-11-07 20:31:50 -05:00
1 parent 3dbd7f2554
commit d01965c00d
16 files changed
+309 -15

No files matched your search

@@ -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 = &copy
}
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 = &copy
}
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")
}
}