Files
cthulhu/internal/app/braille_presentation.go
deedra edd6c560b8 Read Thunderbird message text and preserve link announcements
Thunderbird HTML messages expose readable text on containers around embedded
objects. The browse cursor previously considered only text leaves, and native
document capture omitted heading/link character counts. This skipped message
content, headings and link text. A caret event following Tab could also replace
a complete link announcement with its first character.

Capture owned hypertext child identities and native placeholder offsets on the
GLib thread, releasing native interfaces before returning. Require every child
to occupy one embedded-object character so providers exposing duplicate text
through static descendants retain their ordinary traversal.

Traverse native text ranges before and after embedded children without changing
their offset domains. Preserve character, word, line and document-boundary
navigation; normalize native caret and Braille routing positions at adjacent
embedded links. Keep the Braille cursor on the correct fragment when a parent
contributes text more than once to an assembled line.

Ignore same-input link caret echoes after focus while retaining subsequent
arrow and selection feedback. Add regression tests, extend the private Firefox
fixture, and record behavior and remaining acceptance in the parity ledger.

Validation:
- Full Go test suite with count=1; focused app/atspi/review race checks.
- go vet ./...; reader and brlapi helper builds.
- Private Firefox baseline, mixed-parent offsets and tab navigation.
- Private Chromium fragments, named links, document navigation and selection.
- Parity ledger validation and whitespace checks.

The existing Firefox tab test intermittently timed out on both an untouched
HEAD copy and the changed tree; the final run passed. The unchanged
TestRunReachesSoundStopAfterEarlierServiceStopError also failed once during
startup cancellation and passed in subsequent full-suite and race runs.

Both installed binaries were verified and the reader reloaded in its original
desktop session. The maintainer confirmed readable message text and later
working links after an earlier intermittent omission. That omission remains
untraced; repeat live acceptance and physical Braille verification remain open.
2026-10-20 10:41:38 -07:00

435 lines
15 KiB
Go

package app
import (
"fmt"
"math"
"strings"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/review"
)
type brailleFrameEnqueuer interface{ EnqueueFrame(braille.Frame) }
// Browse cells map to native object offsets under the page's review epoch.
func brailleDocumentFrame(result review.Result, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
snapshot := result.DocumentPosition.Snapshot
line, assembled := result.DocumentBrailleLine, result.DocumentCaretLine
if line == nil && assembled == nil || snapshot.Role == "password text" {
frame := brailleFocusFrameWithOptions(snapshot, options, configured...)
frame.Routes = nil
textLength := utf8.RuneCountInString(snapshot.Name)
if textLength == 0 {
textLength = utf8.RuneCountInString(snapshot.Text)
}
return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength)
}
frame := braille.Frame{Cursor: -1, WordWrap: options.WordWrap}
appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int) {
frame.Text += separator
start := utf8.RuneCountInString(frame.Text)
frame.Text += raw
length := utf8.RuneCountInString(raw)
if source.ID() == snapshot.ID() && result.DocumentPosition.Offset >= sourceStart && result.DocumentPosition.Offset <= sourceStart+length {
frame.Cursor = start + result.DocumentPosition.Offset - sourceStart
}
if source.ID().Valid() && source.Role != "password text" && length > 0 {
frame.Routes = append(frame.Routes, braille.TextRoute{Start: start, End: start + length,
SourceStart: sourceStart, TextLength: length, BusName: source.BusName, Path: source.Path, SourceText: raw})
}
if options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...)
for offset := max(source.SelectionStart, sourceStart); offset < min(source.SelectionEnd, sourceStart+length); offset++ {
frame.Dots[start+offset-sourceStart] |= byte(options.SelectionIndicator)
}
}
isLink := source.Role == "link"
for _, ancestor := range source.BrailleContext {
isLink = isLink || ancestor.Role == "link"
}
if options.LinkIndicator != 0 && isLink {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...)
for offset := start; offset < start+length; offset++ {
frame.Dots[offset] |= byte(options.LinkIndicator)
}
}
if len(configured) != 0 {
if len(source.TextAttributeRuns) != 0 {
for _, run := range source.TextAttributeRuns {
markTextAttributeRun(&frame, run, sourceStart, start, length, configured[0])
}
} else {
markTextAttributeRun(&frame, source.TextAttributeRun, sourceStart, start, length, configured[0])
}
}
}
if assembled != nil {
for index, content := range assembled.Contents {
raw := content.RawText
if index == len(assembled.Contents)-1 {
raw = strings.TrimRight(raw, "\r\n")
}
if content.Snapshot.Role == "password text" {
continue
}
appendContent(content.Snapshot, raw, content.Separator, content.Start)
}
} else {
snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd
snapshot.TextAttributeRuns = line.TextAttributeRuns
appendContent(snapshot, strings.TrimRight(line.Text, "\r\n"), "", line.Start)
}
textLength := utf8.RuneCountInString(frame.Text)
if frame.Cursor < 0 || frame.Cursor > textLength {
frame.Cursor = -1
} else if frame.Cursor == textLength {
frame.Text += " "
for index := range frame.Routes {
if frame.Routes[index].End == textLength {
frame.Routes[index].End++
}
}
}
if result.DocumentFocus.Valid() && result.DocumentPosition.Document.Valid() {
doc := result.DocumentPosition.Document.ID()
frame.Document = &braille.DocumentRoute{Epoch: result.DocumentEpoch, FocusBus: result.DocumentFocus.BusName,
FocusPath: result.DocumentFocus.Path, DocumentBus: doc.BusName, DocumentPath: doc.Path}
} else {
frame.Routes = nil
}
ancestors := append([]atspi.Snapshot{snapshot}, result.DocumentCaretContext...)
for _, ancestor := range ancestors {
if ancestor.Role == "heading" {
frame.Text = brailleJoin(frame.Text, fmt.Sprintf("h%d", ancestor.HeadingLevel))
break
}
}
if options.LinkIndicator != 0 && assembled == nil {
for _, ancestor := range ancestors {
if ancestor.Role == "link" {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...)
for index := 0; index < textLength; index++ {
frame.Dots[index] |= byte(options.LinkIndicator)
}
break
}
}
}
if len(frame.Dots) != 0 {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...)
}
return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength)
}
func linkedHeadingBraille(frame braille.Frame, link *atspi.Snapshot, options config.BrailleOptions, textLength int) braille.Frame {
if link == nil {
return frame
}
contentLength := utf8.RuneCountInString(frame.Text)
textLength = min(contentLength, max(0, textLength))
if options.LinkIndicator != 0 {
if len(frame.Dots) < contentLength {
frame.Dots = append(frame.Dots, make([]byte, contentLength-len(frame.Dots))...)
}
for index := 0; index < textLength; index++ {
frame.Dots[index] |= byte(options.LinkIndicator)
}
}
role := "lnk"
if link.Visited {
role = "vlnk"
}
frame.Text = brailleJoin(frame.Text, role)
if len(frame.Dots) != 0 {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-contentLength)...)
}
return frame
}
// Formats the captured object and bounded context using Python formatting.py's
// braille rules and object_properties.py's indicators.
func brailleFocusFrame(s atspi.Snapshot) braille.Frame {
return brailleFocusFrameWithOptions(s, config.DefaultSettings().Braille)
}
func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
return braille_context_frame(braille_object_frame(s, options, configured...), s, options)
}
func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame {
if frame, handled := libreOfficeBrailleFrame(s, options); handled {
return frame
}
name, role := strings.TrimSpace(s.Name), s.Role
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
label := name
if role == "terminal" || role == "heading" || role == "static" {
label = ""
}
line := strings.TrimRight(s.BrailleLine, "\r\n")
if label == line {
label = ""
}
prefix := ""
if label != "" {
prefix = label + " "
}
text := prefix + line
end := utf8.RuneCountInString(text)
cursor := -1
relative := s.BrailleCaret - s.BrailleLineStart
if s.TextCaretKnown && relative >= 0 && relative <= utf8.RuneCountInString(s.BrailleLine) {
cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line))
}
if role != "terminal" && role != "heading" && !options.DisableEOL {
text += " $l"
} else if cursor == end {
text += " "
}
frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap}
if options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart {
frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text))
start := utf8.RuneCountInString(prefix)
for source := max(s.SelectionStart, s.BrailleLineStart); source < min(s.SelectionEnd, s.BrailleLineStart+utf8.RuneCountInString(line)); source++ {
frame.Dots[start+source-s.BrailleLineStart] = byte(options.SelectionIndicator)
}
}
if options.LinkIndicator != 0 {
for _, ancestor := range s.BrailleContext {
if ancestor.Role != "link" {
continue
}
if len(frame.Dots) == 0 {
frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text))
}
start := utf8.RuneCountInString(prefix)
for index := start; index < start+utf8.RuneCountInString(line); index++ {
frame.Dots[index] |= byte(options.LinkIndicator)
}
break
}
}
if len(configured) != 0 {
if len(s.TextAttributeRuns) != 0 {
for _, run := range s.TextAttributeRuns {
markTextAttributeRun(&frame, run, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0])
}
} else {
markTextAttributeRun(&frame, s.TextAttributeRun, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0])
}
}
// Only actual text and its EOL marker route; labels and state suffixes do not.
if s.TextCaretKnown && s.ID().Valid() {
frame.Routes = []braille.TextRoute{{Start: utf8.RuneCountInString(prefix), End: utf8.RuneCountInString(text),
SourceStart: s.BrailleLineStart, TextLength: utf8.RuneCountInString(line),
BusName: s.BusName, Path: s.Path, SourceText: s.BrailleLine}}
}
if role == "heading" {
frame.Text = brailleJoin(frame.Text, fmt.Sprintf("h%d", s.HeadingLevel))
} else if role != "terminal" {
if s.Invalid && role != "spin button" && role != "document frame" && role != "document web" {
frame.Text += " invalid"
}
if s.Required {
frame.Text += " required"
}
if s.ReadOnly {
frame.Text += " rdonly"
}
}
if len(frame.Dots) != 0 && len(frame.Dots) < utf8.RuneCountInString(frame.Text) {
frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...)
}
return frame
}
// Protected text is never copied, even if a provider supplies it accidentally.
if role == "password text" {
return braille.Frame{Text: brailleJoin(name, role), Cursor: 0}
}
roleName := role
switch role {
case "invalid", "unknown", "redundant object", "filler", "extended", "link":
roleName = ""
case "heading":
roleName = fmt.Sprintf("h%d", s.HeadingLevel)
}
if options.Verbosity == 0 && role != "heading" {
roleName = ""
}
if options.AbbreviateRoles && roleName != "" && role != "heading" {
roleName = shortBrailleRole(roleName)
}
expanded := ""
if s.Expandable {
expanded = "collapsed"
if s.Expanded {
expanded = "expanded"
}
}
available := ""
if s.SensitiveKnown && !s.Sensitive {
available = "grayed"
}
indicator := ""
if s.Checked {
indicator = "<x>"
} else if s.Indeterminate {
indicator = "<->"
} else {
indicator = "< >"
}
value := brailleValue(s)
text := ""
cursor := 0
switch role {
case "check box":
text = brailleJoin(indicator, name, roleName)
case "check menu item":
text = brailleJoin(indicator, name, roleName, available)
case "radio button", "radio menu item", "toggle button":
indicator = "& y"
if s.Checked {
indicator = "&=y"
}
if name == "" {
name = s.Description
}
if role == "toggle button" {
text = brailleJoin(indicator, name, expanded, roleName)
} else {
text = brailleJoin(indicator, name, roleName)
if role == "radio menu item" {
text = brailleJoin(text, available)
}
}
case "push button", "button":
if name == "" {
name = s.Description
}
text = brailleJoin(name, expanded, roleName)
case "menu item":
text = brailleJoin(name, expanded, available)
case "tree item":
text = brailleJoin(name, expanded)
case "list item":
text = brailleJoin(name, expanded, roleName, available)
case "menu", "page tab":
text = brailleJoin(name, roleName, available)
case "combo box":
if value == "" {
value = s.Description
}
text = brailleJoin(name, value, roleName)
if name != "" && value != "" {
cursor = utf8.RuneCountInString(name) + 1
}
case "dial", "scroll bar", "slider", "spin button":
text = brailleJoin(name, value, roleName)
if s.Required {
text = brailleJoin(text, "required")
}
if role == "slider" && s.Invalid {
text = brailleJoin(text, "invalid")
}
case "progress bar":
text = brailleJoin(name, value, roleName)
default:
displayed := s.Text
if displayed == name {
displayed = ""
}
text = brailleJoin(name, displayed, value, roleName)
if s.Required {
text = brailleJoin(text, "required")
}
if s.Invalid {
text = brailleJoin(text, "invalid")
}
}
if text == "" {
cursor = -1
}
frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap}
if role == "link" && options.LinkIndicator != 0 {
frame.Dots = make([]byte, utf8.RuneCountInString(text))
for index := range frame.Dots {
frame.Dots[index] |= byte(options.LinkIndicator)
}
}
return frame
}
func shortBrailleRole(role string) string {
if short, ok := map[string]string{
"check box": "chk", "check menu item": "chk", "radio button": "radio",
"push button": "btn", "button": "btn", "combo box": "cbo",
"list item": "lstitm", "page tab": "pgt", "text": "txt",
"link": "lnk", "slider": "sldr", "spin button": "spin",
"menu": "mnu", "menu item": "mnuitm", "progress bar": "pgbar",
"toggle button": "tglbtn", "password text": "pwd", "label": "lbl",
"application": "app", "frame": "frm", "dialog": "dlg", "panel": "pnl",
"list": "lst", "table": "tbl", "table cell": "cll",
"column header": "colhdr", "row header": "rwhdr",
}[role]; ok {
return short
}
return role
}
func brailleTextRole(role string) bool {
switch role {
case "entry", "text", "static", "paragraph", "section", "label", "page", "document frame", "document web", "heading", "spin button":
return true
}
return false
}
func brailleJoin(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, " ")
}
func brailleValue(s atspi.Snapshot) string {
if s.Role == "progress bar" {
if !s.ValueKnown || s.Indeterminate && s.CurrentValue <= 0 {
return ""
}
if s.MaximumValue == s.MinimumValue {
if s.CurrentValue == s.MaximumValue && s.CurrentValue >= 1 && s.CurrentValue <= 100 {
return fmt.Sprintf("%.0f%%", math.Trunc(s.CurrentValue))
}
return ""
}
value := (s.CurrentValue - s.MinimumValue) / (s.MaximumValue - s.MinimumValue) * 100
if math.IsNaN(value) || math.IsInf(value, 0) {
return ""
}
return fmt.Sprintf("%.0f%%", math.Trunc(value))
}
if s.ValueText != "" {
return s.ValueText
}
if !s.ValueKnown || math.IsNaN(s.CurrentValue) || math.IsInf(s.CurrentValue, 0) {
return ""
}
increment := s.MinimumIncrement
if increment < 0 || math.IsNaN(increment) || math.IsInf(increment, 0) {
return ""
}
places := 0
if increment > 0 {
places = int(math.Ceil(math.Max(0, -math.Log10(increment))))
} else if math.Abs(s.CurrentValue) < 1 {
places = 1
}
return fmt.Sprintf("%.*f", places, s.CurrentValue)
}