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

+127
View File
@@ -0,0 +1,127 @@
package presentation
import (
"fmt"
"strings"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/sound"
)
// DocumentLineText mirrors the web script's content presentation: join the
// displayed text for each same-line object and speak common role/state details.
func DocumentLineText(line atspi.DocumentLine) string {
return DocumentLineTextWithOptions(line, DefaultOptions())
}
func DocumentLineTextWithOptions(line atspi.DocumentLine, options Options) string {
return DocumentLineSpeechWithOptions(line, options).Text
}
func DocumentLineSpeechWithOptions(line atspi.DocumentLine, options Options) Speech {
reading := reading_options(options)
var parts []string
seen := make(map[atspi.ObjectID]bool)
type rowKey struct {
bus, path string
row int
}
rows := make(map[rowKey]bool)
for _, content := range line.Contents {
text := strings.TrimSpace(content.Text)
if content.Snapshot.Role == "table cell" && content.Snapshot.TableRowText != "" && FullRowEnabled(content.Snapshot, options) {
key := rowKey{content.Snapshot.BusName, content.Snapshot.TablePath, content.Snapshot.TableRowIndex}
if key.path != "" {
if rows[key] {
continue
}
rows[key] = true
}
text = content.Snapshot.TableRowText
}
if text == "" {
text = strings.TrimSpace(content.Snapshot.Name)
}
appendPart(&parts, text)
if reading.OnlySpeakDisplayedText {
continue
}
id := content.Snapshot.ID()
if seen[id] {
continue
}
_, roleCue := roleSoundCue(content.Snapshot, options)
_, stateCue := stateSoundCue(content.Snapshot, options)
role := lineRole(content.Snapshot)
if options.SpeechVerbosityLevel == 0 && (role == "icon" || role == "canvas") {
role = ""
}
if role != "" && !((roleCue || stateCue) && options.RoleSoundPresentation == "sound_only") {
appendPart(&parts, role)
}
states := controlStates(content.Snapshot)
if stateCue && options.RoleSoundPresentation == "sound_only" {
states = removeReplacedState(states)
}
for _, state := range states {
appendPart(&parts, state)
}
appendPart(&parts, controlValue(content.Snapshot))
if reading.SpeakMisspelledIndicator && content.Snapshot.Misspelled {
appendPart(&parts, "misspelled")
}
seen[id] = true
}
if len(parts) == 0 {
if !options.SpeakBlankLines {
return Speech{}
}
return Speech{Text: "blank"}
}
return SpeechWithOptions(parts, options)
}
// A text object can contribute several ranges to a visual line. Present its
// role and state sounds once, in document order.
func DocumentLineSoundCues(line atspi.DocumentLine, options Options) []sound.Cue {
var cues []sound.Cue
seen := make(map[atspi.ObjectID]bool)
for _, content := range line.Contents {
id := content.Snapshot.ID()
if !seen[id] {
cues = append(cues, SoundCues(content.Snapshot, options)...)
seen[id] = true
}
}
return cues
}
func appendPart(parts *[]string, part string) {
if part == "" {
return
}
*parts = append(*parts, part)
}
// Keep this deliberately small: line reading presents displayed text plus
// the role/state details Python's web generator keeps for common content.
func lineRole(snapshot atspi.Snapshot) string {
switch snapshot.Role {
case "heading":
if snapshot.HeadingLevel > 0 {
return fmt.Sprintf("heading level %d", snapshot.HeadingLevel)
}
return "heading"
case "link":
if snapshot.Visited {
return "visited link"
}
return "link"
case "footer", "form", "label", "menu item", "paragraph", "section",
"redundant object", "unknown", "table cell", "text", "static",
"content suggestion", "list item", "list", "document frame",
"document web", "alert", "row header", "toggle button":
return ""
}
return snapshot.Role
}