Files
cthulhu/internal/review/document_caret_nodes.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)
}