Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+431
View File
@@ -0,0 +1,431 @@
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() {
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 {
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)
}