101 lines
3.7 KiB
Go
101 lines
3.7 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
func wholeCaretNode(snapshot atspi.Snapshot) bool {
|
|
if snapshot.Role == "link" {
|
|
return snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != ""
|
|
}
|
|
if snapshot.Role == "image" {
|
|
return snapshot.Name != "" || snapshot.Description != ""
|
|
}
|
|
switch snapshot.Role {
|
|
case "button", "check box", "check menu item", "list box", "menu item", "page tab",
|
|
"radio menu item", "radio button", "push button", "toggle button":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// caret_node_indices preserves provider order and treats whole controls as one
|
|
// stop. MoveCaret holds the document cursor lock while using these indices.
|
|
func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int) {
|
|
// Container text normally embeds its child text. Whole controls are one
|
|
// caret stop even when their label is a child or they have no text interface.
|
|
// Python's web _treatObjectAsWhole makes the same distinction.
|
|
indices := make([]int, 0, len(nodes))
|
|
current := -1
|
|
skipped := make(map[atspi.ObjectID]bool)
|
|
protected := make(map[atspi.ObjectID]bool, len(nodes))
|
|
var skipChildren func([]atspi.ObjectID)
|
|
children := make(map[atspi.ObjectID][]atspi.ObjectID, len(nodes))
|
|
for _, node := range nodes {
|
|
children[node.Reference.ID()] = node.Children
|
|
}
|
|
skipChildren = func(ids []atspi.ObjectID) {
|
|
for _, id := range ids {
|
|
if skipped[id] {
|
|
continue
|
|
}
|
|
skipped[id] = true
|
|
skipChildren(children[id])
|
|
}
|
|
}
|
|
for index, node := range nodes {
|
|
snapshot := node.Snapshot
|
|
// Icon-font text inside links, controls, editable text or code can
|
|
// carry meaning. Preserve that entire subtree, including static leaves.
|
|
protected[node.Reference.ID()] = protected[node.Parent] || snapshot.Editable ||
|
|
!atspi.IsWebDocumentRole(snapshot.Role) && (snapshot.Focusable || snapshot.SupportsClick || snapshot.ExplicitName) ||
|
|
snapshot.Role == "link" || wholeCaretNode(snapshot) ||
|
|
snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" ||
|
|
strings.Contains(" "+snapshot.XMLRoles+" ", " code ")
|
|
if node.Reference == object {
|
|
current = index
|
|
}
|
|
if node.Hidden || skipped[node.Reference.ID()] {
|
|
continue
|
|
}
|
|
if !protected[node.Reference.ID()] && decorative_caret_glyph(node) {
|
|
continue
|
|
}
|
|
if wholeCaretNode(node.Snapshot) {
|
|
indices = append(indices, index)
|
|
skipChildren(node.Children)
|
|
} else if node.Snapshot.Editable &&
|
|
(node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") &&
|
|
(node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) {
|
|
indices = append(indices, index)
|
|
} else if !node.Snapshot.Editable && len(node.Children) == 0 && node.Snapshot.CharacterCount > 0 {
|
|
indices = append(indices, index)
|
|
}
|
|
}
|
|
return indices, current
|
|
}
|
|
|
|
// Python's web hasValidName/isUselessEmptyElement rejects standalone unnamed
|
|
// private-use icons. Keep this narrower: only a known non-interactive static
|
|
// leaf with one matching name/text character is omitted from browse reading.
|
|
func decorative_caret_glyph(node atspi.DocumentNode) bool {
|
|
snapshot := node.Snapshot
|
|
if snapshot.Role != "static" || !snapshot.StatesKnown || snapshot.Focusable ||
|
|
snapshot.Editable || node.ParentEditable || snapshot.SupportsClick || snapshot.ExplicitName ||
|
|
snapshot.Description != "" || len(node.Children) != 0 || snapshot.CharacterCount != 1 ||
|
|
(snapshot.Text != "" && snapshot.Text != snapshot.Name) {
|
|
return false
|
|
}
|
|
character, size := utf8.DecodeRuneInString(snapshot.Name)
|
|
return size == len(snapshot.Name) && unicode.Is(unicode.Co, character)
|
|
}
|