Files
cthulhu/internal/review/document_caret_nodes.go

147 lines
5.8 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 wholeCaretControl(snapshot) {
return true
}
// An explicitly named custom clickable is an atomic stop just like a
// named link. Ordinary clickable text remains readable by character/word.
return snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role)
}
func wholeCaretControl(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
}
// Passive tooltips appear when their owner receives focus and disappear when
// caret placement moves focus away. Entering their text can strand navigation
// on a removed object and repeatedly recover to the owner's container. Match
// Orca's web caret policy while retaining focusable/editable tooltip content.
func passive_caret_tooltip(snapshot atspi.Snapshot) bool {
return snapshot.Role == "tool tip" && !snapshot.Focusable && !snapshot.Editable
}
// 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, map[atspi.ObjectID]bool) {
// 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))
// Named custom clickables can contain independent controls, including
// disabled entries. Keep the container's label as a stop without pruning
// those controls. Ordinary labels and semantic controls remain atomic.
controls := make(map[atspi.ObjectID]bool)
for index := len(nodes) - 1; index >= 0; index-- {
node := nodes[index]
snapshot := node.Snapshot
field := documentFormFieldRole(snapshot) &&
(snapshot.Role != "text" || snapshot.Editable || snapshot.Focusable || snapshot.ReadOnly)
if !node.Hidden && (controls[node.Reference.ID()] || field || snapshot.Editable || snapshot.Role == "link") {
controls[node.Parent] = true
}
}
expanded := make(map[atspi.ObjectID]bool)
for _, node := range nodes {
if controls[node.Reference.ID()] && wholeCaretNode(node.Snapshot) && !wholeCaretControl(node.Snapshot) {
expanded[node.Reference.ID()] = true
}
}
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 snapshot.Role == "password text" || passive_caret_tooltip(snapshot) {
skipChildren(node.Children)
continue
}
if !protected[node.Reference.ID()] && decorative_caret_glyph(node) {
continue
}
if wholeCaretNode(node.Snapshot) {
indices = append(indices, index)
if !expanded[node.Reference.ID()] {
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 len(node.Children) == 0 && node.Snapshot.CharacterCount > 0 {
// Rich editors expose their contents as editable text leaves.
// Browse reading must descend into them just like ordinary text;
// the entry's embedded-object placeholders are not its contents.
indices = append(indices, index)
}
}
return indices, current, expanded
}
// 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)
}