// 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) }