510 lines
18 KiB
Go
510 lines
18 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
|
|
name := strings.TrimSpace(stripEmbeddedObjects(snapshot.Name))
|
|
if snapshot.Role == "link" && snapshot.ExplicitName && name != "" && !snapshot.Editable {
|
|
// Accessible-label review has no native text route.
|
|
frame.Cursor = min(result.DocumentPosition.Offset, utf8.RuneCountInString(name))
|
|
}
|
|
textLength := utf8.RuneCountInString(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}
|
|
// native reports that raw maps one-to-one onto native offsets at
|
|
// sourceStart. Expanded or placeholder-stripped text keeps display
|
|
// order but gets no routes or native-offset indicators, and only a
|
|
// caret at the very start of the span can be placed honestly.
|
|
appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int, native bool) {
|
|
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 {
|
|
if native {
|
|
frame.Cursor = start + result.DocumentPosition.Offset - sourceStart
|
|
} else if result.DocumentPosition.Offset == sourceStart {
|
|
frame.Cursor = start
|
|
}
|
|
}
|
|
if native && 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 native && 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 native && 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 {
|
|
strippedObjects := false
|
|
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
|
|
}
|
|
// A placeholder fragment stands for child objects presented
|
|
// as their own contents; drop it instead of duplicating them.
|
|
native := !strings.ContainsRune(raw, '\ufffc')
|
|
if !native {
|
|
strippedObjects = true
|
|
raw = stripEmbeddedObjects(raw)
|
|
}
|
|
appendContent(content.Snapshot, raw, content.Separator, content.Start, native)
|
|
}
|
|
if strippedObjects && strings.TrimSpace(frame.Text) == "" {
|
|
// Nothing but placeholders survived assembly; fall back to
|
|
// the displayed text speech would use for this object.
|
|
if text := brailleDisplayedText(snapshot); text != "" {
|
|
frame.Text, frame.Routes, frame.Dots = text, nil, nil
|
|
if frame.Cursor > 0 {
|
|
frame.Cursor = 0
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
text := strings.TrimRight(line.Text, "\r\n")
|
|
native := !line.Expanded && !strings.ContainsRune(text, '\ufffc')
|
|
if !native {
|
|
text = stripEmbeddedObjects(text)
|
|
if strings.TrimSpace(text) == "" {
|
|
text = brailleDisplayedText(snapshot)
|
|
}
|
|
}
|
|
if native {
|
|
snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd
|
|
snapshot.TextAttributeRuns = line.TextAttributeRuns
|
|
}
|
|
appendContent(snapshot, text, "", line.Start, native)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Embedded-object placeholders stand for child accessibles in native text.
|
|
// They are structure, not displayed characters, and must never reach a frame.
|
|
func stripEmbeddedObjects(value string) string {
|
|
return strings.ReplaceAll(value, "\ufffc", "")
|
|
}
|
|
|
|
// brailleDisplayedText returns the text speech uses for an object whose
|
|
// native line is only placeholders: expanded text first, then the name.
|
|
func brailleDisplayedText(snapshot atspi.Snapshot) string {
|
|
for _, candidate := range []string{snapshot.Text, snapshot.Name} {
|
|
if candidate = strings.TrimSpace(candidate); candidate != "" && !strings.ContainsRune(candidate, '\ufffc') {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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(stripEmbeddedObjects(s.Name)), s.ControlRole()
|
|
if role == "terminal" || brailleTextRole(role) && s.TextCaretKnown {
|
|
label := name
|
|
if role == "terminal" || role == "heading" || role == "static" {
|
|
label = ""
|
|
}
|
|
line := strings.TrimRight(s.BrailleLine, "\r\n")
|
|
// A line containing embedded-object placeholders cannot map cells
|
|
// to native offsets. Prefer the expanded capture of the same line,
|
|
// then real surrounding text, then the accessible name.
|
|
native := !strings.ContainsRune(line, '\ufffc')
|
|
if !native {
|
|
if expanded := strings.TrimSpace(s.Text); expanded != "" && !strings.ContainsRune(expanded, '\ufffc') {
|
|
line = expanded
|
|
} else if stripped := stripEmbeddedObjects(line); strings.TrimSpace(stripped) != "" {
|
|
line = stripped
|
|
} else {
|
|
line = brailleDisplayedText(s)
|
|
}
|
|
}
|
|
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) {
|
|
if native {
|
|
cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line))
|
|
} else if relative == 0 {
|
|
cursor = utf8.RuneCountInString(prefix)
|
|
}
|
|
}
|
|
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 native && 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 native && 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 native && 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 := stripEmbeddedObjects(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)
|
|
}
|